Compare commits

...
448 Commits
Author SHA1 Message Date
Tim Baek 6c159a97b7 Merge pull request #22390 from open-webui/dev
refac
2026-03-08 06:56:22 +04:00
Timothy Jaeryang Baek 124ad948fe refac 2026-03-07 20:56:05 -06:00
Tim Baek 947dcd34bd Merge pull request #22385 from open-webui/dev
0.8.9
2026-03-08 06:47:14 +04:00
Timothy Jaeryang Baek 710b5270a1 refac 2026-03-07 20:43:45 -06:00
Timothy Jaeryang Baek 2bff50f736 refac 2026-03-07 20:42:21 -06:00
Timothy Jaeryang Baek e24299e66d refac 2026-03-07 20:36:54 -06:00
Timothy Jaeryang Baek f047b6b3ae refac 2026-03-07 20:30:42 -06:00
Timothy Jaeryang Baek 368912ca62 refac 2026-03-07 20:28:17 -06:00
Timothy Jaeryang Baek b1048fc9bc refac 2026-03-07 20:22:01 -06:00
Timothy Jaeryang Baek 9bb226dc52 refac 2026-03-07 20:21:33 -06:00
Timothy Jaeryang Baek 0948235c3b refac 2026-03-07 20:21:06 -06:00
Timothy Jaeryang Baek bd456ed10b doc: changelog 2026-03-07 20:17:51 -06:00
Classic298 223c14f48b fix: add deterministic tiebreaker to all paginated chat queries (#22387)
Add Chat.id as a secondary sort key to all paginated chat queries
that use offset/limit pagination. When multiple chats share the same
updated_at timestamp, the database does not guarantee a stable order
across page boundaries, causing chats to appear on multiple pages.

This produces duplicate keys in the Svelte sidebar each-block
(each_key_duplicate error). Adding Chat.id as a tiebreaker ensures
fully deterministic ordering.

Extends the fix from #22383 (which addressed get_chat_ids_by_model_id)
to all remaining paginated chat queries.
2026-03-07 20:16:50 -06:00
Classic298 d0c3180376 changelog: 0.8.9 (#22186)
* changelog: terminal keepalive fix

* changelog: add chat archive handler fix

* changelog: terminal keepalive, chat archive handler, BeautifulSoup4 dependency

* changelog: button spinner, terminal keepalive, chat archive, beautifulsoup4

* changelog: group users sort, button spinner, terminal keepalive, chat archive, beautifulsoup4

* changelog: add event call timeout configuration

* changelog: add general improvements and French translations

* changelog: file refresh button, group sort, event timeout, translations

* changelog: add office file previews support

* changelog: add Open Terminal port viewing feature

* changelog: add Open Terminal video previews entry

* changelog: Open Terminal syntax highlighting and XLSX improvements

* changelog: add JSON tree view and SVG rendering entry

* changelog: add Open Terminal Jupyter Notebook previews entry

* changelog: update chat performance entry to reflect broader markdown rendering improvements

* changelog: add SQLite browser feature to Open Terminal

* changelog: add Open Terminal file copy button entry

* changelog: add Open Terminal auto-refresh entry

* changelog: open terminal, mermaid, diagrams

* changelog: add Open Terminal notebook cell execution entry

* changelog: reorder Open Terminal entries by impact

* changelog: add initial page load speed entry

* changelog: opentelemetry, metrics, telemetry

* changelog: fix artifacts memory leak (PR #22303)

* changelog: message list performance, array operations optimization

* changelog: streaming markdown performance fix

* changelog: sqlcipher, stability, memory leak

* changelog: chat streaming performance

* changelog: fix Floating Quick Actions for unavailable models

* changelog: follow-up suggestions, prompt template, JSON format

* changelog: banner, navigation, homepage

* changelog: api middleware, streaming performance

* changelog: tts, thinking content, playback

* changelog: add system metrics via OpenTelemetry

* changelog: tool access permissions fix

* changelog: source list performance fix

* changelog: source list performance optimization

* changelog: chat message tree performance, #22194

* changelog: add Finnish translations, update version date

* changelog: fix parameterless tool calls during streaming

* changelog: add stale pinned models cleanup fix

* changelog: move performance entries from Fixed to Added section

* changelog: android, photo capture, canvas

* changelog: action priority query optimization (PR #22301)

* changelog: move action priority query to Added section

* changelog: group Open Terminal entries together

* changelog: group Open Terminal entries together

* changelog: move API key middleware entry to Added section

* changelog: open-terminal, html-editing

* changelog: web search tool guidance update

* changelog: add Turkish translations to v0.8.9

* changelog: add German translations

* changelog: fix stop sequence error handling

* changelog: Open Terminal permission fix for issue #22374

* changelog: add Windows path fix for Open Terminal

* changelog: add Simplified Chinese and Traditional Chinese to translations

* changelog: fix profile image sizing in chat overview

* changelog: queued messages display fix (#22176)

* changelog: model list loading performance optimization

* changelog: model list performance optimization update

* changelog: artifacts reactive loop fix

* changelog: artifact navigation fix

* changelog: fix image generation in temporary chats

* changelog: non-streaming token tracking, admin analytics

* changelog: add citation parser error handling fix

* changelog: tool server URL trailing slash fix

* changelog: inline code typing fix (#20417)

* changelog: variable input newlines fix

* changelog: add migration memory usage fix for large deployments

* changelog: Microsoft OAuth refresh token fix

* changelog: add issue link to variable input newlines entry

* changelog: tool files access, artifact thinking block fix

* changelog: ollama, model unload, proxy fix

* changelog: fix banner type dropdown requiring two clicks

* changelog: move migration memory fix to top of Fixed section

* changelog: fix analytics URL encoding for models with slashes

* changelog: fix tool call streaming for GPT-5 models

* changelog: fix analytics chat list duplicate error

* changelog: pyodide file system support for code interpreter

* changelog: fix folder knowledge base native tool call duplicate query

* changelog: folder knowledge base native tool call fix with follow-up commit

* changelog: nested folders support

* changelog: update Pyodide file system entry with pip guidance
2026-03-07 20:15:00 -06:00
Timothy Jaeryang Baek 3ceaa107ab chore: format 2026-03-07 20:14:32 -06:00
Timothy Jaeryang Baek 144d8b1bb7 refac 2026-03-07 20:12:35 -06:00
Timothy Jaeryang Baek 989938856f refac 2026-03-07 20:05:18 -06:00
Timothy Jaeryang BaekandColin Chen 8913f37c3d enh: create subfolder
Co-Authored-By: Colin Chen <1207878+silenceroom@users.noreply.github.com>
2026-03-07 19:45:43 -06:00
Timothy Jaeryang Baek 80b5896b70 refac 2026-03-07 19:38:20 -06:00
Timothy Jaeryang Baek 967b1137dc refac 2026-03-07 19:31:51 -06:00
Timothy Jaeryang Baek 8cd3bd7997 refac 2026-03-07 19:28:57 -06:00
Timothy Jaeryang Baek ce0ca894fe enh: code interpreter pyodide fs 2026-03-07 19:23:18 -06:00
Classic298 d1975b740b fix: add deterministic ordering to chat_ids pagination query to prevent duplicates (#22383) 2026-03-07 20:19:44 -05:00
Timothy Jaeryang Baek 459a60a242 refac 2026-03-07 19:17:24 -06:00
Classic298 9a269ec8ab fix: use path converter for model ID routes in analytics to support slashes (#22382) 2026-03-07 20:02:59 -05:00
Timothy Jaeryang Baek d7efdcce2b refac 2026-03-07 19:02:03 -06:00
Timothy Jaeryang Baek 885c94bda8 refac 2026-03-07 18:51:20 -06:00
Classic298 2e1ef805ff fix: banner type dropdown requires two selections to register (#22378) 2026-03-07 19:30:26 -05:00
Timothy Jaeryang Baek 95b65ff751 refac 2026-03-07 18:23:52 -06:00
Timothy Jaeryang Baek 35bc831077 refac 2026-03-07 18:18:02 -06:00
pedro-inf-custodio 5d4505c685 fix: add support for scope in OAuth refresh token request (#22359)
* fix: add support for scope in OAuth refresh token request

* add oauth refresh token include scope

* Fix variable import

* Fix env variables import

* Added debug logs WIP

* Remove debug logs
2026-03-07 19:13:28 -05:00
Classic298 b4f340806a fix: migration streaming/batching (#21542)
* fix: normalize usage tokens + migration streaming/batching

- Migration: replace .fetchall() with yield_per streaming, replace per-message INSERT+SAVEPOINT with batched inserts (5k/batch) with fallback to row-by-row on error, add progress logging

- Write path: call normalize_usage() in upsert_message() before saving to ensure input_tokens/output_tokens always present

- Read path: analytics queries now COALESCE across input_tokens/prompt_tokens and output_tokens/completion_tokens so historical data with OpenAI-format keys is visible

* fix: restore defensive timestamp conversion in migration

Re-add try/except around int(float(timestamp)) that was accidentally dropped. Without this, a non-numeric timestamp string would cause a TypeError on the subsequent comparison, breaking the entire upgrade.

* revert: remove changes to chat_messages.py
2026-03-07 19:08:11 -05:00
Timothy Jaeryang Baek 7b2f597b30 refac 2026-03-07 17:52:58 -06:00
Timothy Jaeryang BaekandAbdul Moiz e303c3da3b refac: inline codespan rich text input
Co-Authored-By: Abdul Moiz <86627657+abdulmoizjawed@users.noreply.github.com>
2026-03-07 17:45:00 -06:00
Timothy Jaeryang Baek bc5d519c4f refac 2026-03-07 17:29:24 -06:00
Timothy Jaeryang Baek 7cdff6b1e2 refac 2026-03-07 17:24:17 -06:00
Timothy Jaeryang Baek b04de83c20 refac 2026-03-07 17:18:46 -06:00
Classic298 dfa2511199 fix: persist token usage data for non-streaming chat responses (#22166)
The non-streaming response handler was saving assistant messages without
their usage/token data. While the streaming handler correctly extracted
and saved usage information, the non-streaming path discarded it entirely.

This caused assistant messages from non-streaming completions to have
NULL usage in the chat_message table, making them invisible to the
analytics token aggregation queries and contributing to the '0 tokens'
display in Admin Panel Analytics.

Extract and normalize the usage data from the API response and include
it in the database upsert, matching the pattern already used by the
streaming handler.
2026-03-07 17:17:36 -06:00
Timothy Jaeryang Baek d4faa5a5ea refac 2026-03-07 17:13:19 -06:00
Classic298 2108f420ea chore: dep bump (#22305)
* chore: dep bump

* revert: Brotli dependency bump (1.2.0 -> 1.1.0)
2026-03-07 17:12:22 -06:00
Timothy Jaeryang Baek 42ecdb5407 refac 2026-03-07 17:11:44 -06:00
Timothy Jaeryang Baek 626fcff417 refac 2026-03-07 17:06:30 -06:00
Timothy Jaeryang Baek e6b00a8905 refac 2026-03-07 17:03:23 -06:00
Timothy Jaeryang Baek 03c6caac1f refac 2026-03-07 17:02:02 -06:00
Timothy Jaeryang Baek 29160741a3 refac 2026-03-07 16:59:06 -06:00
Shirasawa 7820a311ba fix: prevent message queue from overflowing screen (#22176) 2026-03-07 16:53:28 -06:00
Shirasawa 5eb9b58488 feat: Avoid overview profile image squashed (#22261) 2026-03-07 16:51:56 -06:00
Shirasawa 51a2d2b701 i18n: improve Chinese translation (#22351) 2026-03-07 16:51:04 -06:00
Timothy Jaeryang Baek 044fd1bd15 refac 2026-03-07 16:49:26 -06:00
Timothy Jaeryang Baek 70a31a9a57 fix: terminals button ui 2026-03-07 16:40:14 -06:00
Timothy Jaeryang Baek c7d1d1e390 refac 2026-03-07 16:36:20 -06:00
Classic298 2d0b94794f Update translation.json (#22353) 2026-03-07 16:35:25 -06:00
alifurkanstahlandMSI I9 12900KS RTX fbf315e624 i18n: expand Turkish translations across missing frontend UI strings (#22360)
* feat(i18n): add Turkish translations for access and add-action strings

* feat(i18n): add Turkish translations for access, permission, and upload strings

* feat(i18n): add Turkish translations for API, archive, and attach strings

* feat(i18n): add Turkish translations for chat and channel UI strings

* feat(i18n): add Turkish translations for common UI actions and dialogs

* feat(i18n): add Turkish translations for copy, create, and delete UI strings

* feat(i18n): add Turkish translations for display, download, and edit UI strings

* feat(i18n): add Turkish translations for form inputs, errors, and file UI string

* feat(i18n): add Turkish translations for skill-related UI strings

* i18n: add Turkish translations for settings-related UI strings

* i18n: add Turkish translations for terminal-related UI strings

* i18n: add Turkish translations for misc frontend UI strings

* i18n: add Turkish translations for search-related UI strings

---------

Co-authored-by: MSI I9 12900KS RTX <alifurkanstahl@users.noreply.github.com>
2026-03-07 16:31:23 -06:00
Classic298 b9c0a9c3bf enh: prevent models from always using internal knowledge base search first (#22264)
Some models always primarily use the internal knowledge base first before deviating to the web search tool
2026-03-07 16:16:43 -06:00
Timothy Jaeryang Baek 6d9996e599 refac 2026-03-06 20:12:37 -06:00
Timothy Jaeryang Baek 7806cd5aef feat: use CodeMirror editor for HTML source view, hide save in preview mode
- HTML preview (iframe) no longer shows Edit/Save toolbar buttons
- Clicking Source toggle opens CodeMirror editor with syntax highlighting
- Save button appears only in source mode, using saveCodeFile()
- Ctrl+S saving supported via CodeMirror keybinding
2026-03-06 20:00:12 -06:00
Timothy Jaeryang Baek b3622474d7 refac 2026-03-06 16:25:00 -06:00
Timothy Jaeryang Baek d8bb8c58d0 refac 2026-03-06 16:21:42 -06:00
Classic298 d93cb3658d perf(models): batch-fetch function valves to eliminate N+1 queries (#22301)
* perf(models): batch-fetch function valves to eliminate N+1 queries

get_action_priority() called Functions.get_function_valves_by_id()
individually for every action on every model — an N+1 query pattern
that issued one DB round-trip per (action x model) pair.

Add Functions.get_function_valves_by_ids() that fetches all valves in
a single WHERE IN query, then look up each action's valves from the
pre-fetched dict inside get_action_priority().

No functional change — same priority resolution, same sort order.

* Update models.py

* Update models.py
2026-03-06 15:56:01 -06:00
Shirasawa 200fb093b1 fix: Use toBlob on first mobile export to avoid black canvas image on Android (#22317) 2026-03-06 15:48:44 -06:00
Timothy Jaeryang Baek 4ab831b259 refac 2026-03-06 15:42:13 -06:00
Classic298 576ee92438 perf: rewrite createMessagesList from recursive to iterative (#22194)
Replace the recursive spread-based implementation with an iterative
push+reverse approach. The recursive version created a new array at
each level of recursion via spread, resulting in O(d^2) array copies
where d is the conversation depth. The iterative version walks from
the target message to the root, pushes each message, and reverses
once at the end for O(d) total work.

No behavioral change - same input produces the same output array.
2026-03-06 15:36:13 -06:00
Timothy Jaeryang Baek af4500e504 refac 2026-03-06 15:29:38 -06:00
Timothy Jaeryang Baek 016928722c refac 2026-03-06 15:23:29 -06:00
Timothy Jaeryang Baek 73b69ae408 refac 2026-03-06 15:13:21 -06:00
Timothy Jaeryang Baek 80376a3fdc revert 2026-03-06 15:05:36 -06:00
Timothy Jaeryang Baek 305e591ec2 feat: use CodeMirror for always-editable code file preview
- Add FileCodeEditor.svelte: CodeMirror wrapper with auto language
  detection, dark mode, Ctrl+S save, reactive to value/filePath changes
- Replace Shiki read-only highlighting + textarea editing with
  always-editable CodeMirror for code files in FileNav preview
- Show persistent Save button for code files in toolbar
- Non-code text files keep existing Edit/Save/Cancel textarea flow
- SVG retains Shiki highlighting for visual preview mode
2026-03-06 15:03:23 -06:00
Algorithm5838 39deadcab1 perf: convert APIKeyRestrictionMiddleware to pure ASGI (#22188) 2026-03-06 14:54:03 -06:00
Timothy Jaeryang Baek 2153c8ec9f refac 2026-03-06 14:53:09 -06:00
Classic298 a70c718a0d fix: TTS reading thinking content when reasoning has code blocks (#22237)
removeAllDetails() uses replaceOutsideCode() which splits content on
triple-backtick code blocks before applying the details-removal regex.

When thinking/reasoning content inside a <details> block contained
code blocks (backticks survive html.escape), the <details> opening
and </details> closing tags ended up in different split segments,
making the regex unable to match either. This caused thinking content
to leak through to TTS playback.

Fix: add a direct <details> strip (without code-block splitting) as
the first step of getMessageContentParts(), which is the TTS-specific
entry point. This catches the edge case while keeping removeAllDetails
safe for copy-to-clipboard (where legitimate <details> inside code
blocks should be preserved).

Fixes #22197
2026-03-06 14:46:31 -06:00
Classic298 c73efab192 feat: load banners on navigation to homepage, not only on refresh (#22340) 2026-03-06 14:46:00 -06:00
Classic298 ce54b1df23 perf: guard TTS sentence parsing behind showCallOverlay check (#22195)
The chatCompletionEventHandler runs getMessageContentParts() and
removeAllDetails() on every streaming token to extract sentences
for real-time TTS dispatch via CustomEvent('chat'). These functions
perform multiple O(n) regex passes over the full accumulated message
content, resulting in O(n^2) total work over a streaming response.

The only consumer of these events is CallOverlay.svelte, which is
only mounted when showCallOverlay is true. Without the overlay open,
the parsing runs but the dispatched events have no listeners.

Wrap all three TTS parsing blocks in an if () guard
so the expensive regex work is skipped entirely for the vast majority
of users who are not using the voice call feature.
2026-03-06 14:32:05 -06:00
Classic298 16701befe7 fix: show floating action buttons when chat model is unavailable (#22149) 2026-03-06 14:30:24 -06:00
Abdul Moiz 8a6af40d9f fix: correct conflicting output format instruction in follow-up generation prompt (#22212)
The Guidelines section instructed LLMs to return "a JSON array of strings"
while the Output section showed a JSON object with a "follow_ups" key.
This mismatch caused some models to return a top-level array, which the
frontend parser cannot handle (it looks for `{ }` delimiters and the
`follow_ups` key). Updated the guideline to consistently request a JSON
object matching the expected format.

Fixes #22187
2026-03-06 14:25:42 -06:00
Shamil 9cf6108527 feat: add otel system metrics instrumentation (#22265) 2026-03-06 14:24:24 -06:00
Algorithm5838 1c1c1c3100 fix: allow clearing file upload settings (#22336) 2026-03-06 14:23:20 -06:00
Timothy Jaeryang Baek def954134c refac 2026-03-06 14:21:38 -06:00
Timothy Jaeryang BaekandSteven Schveighoffer c85afce702 fix: import
Co-Authored-By: Steven Schveighoffer <580778+schveiguy@users.noreply.github.com>
2026-03-06 14:10:50 -06:00
Algorithm5838 a25ecfa856 perf: skip token parsing when raw content is unchanged (#22183) 2026-03-06 14:08:12 -06:00
Timothy Jaeryang Baek 47b007ef19 refac 2026-03-06 14:07:34 -06:00
Classic298 04fae8b357 fix: use NullPool for SQLCipher engine to prevent segfault (#22273)
The SQLCipher engine used a dummy sqlite:// URL with a creator function,
which caused SQLAlchemy to auto-select SingletonThreadPool. This pool
non-deterministically closes in-use connections when thread count exceeds
pool_size (default 5), leading to use-after-free segfaults (exit code 139)
in the native sqlcipher3 C library during multi-threaded operations like
user signup.

Now defaults to NullPool (each operation creates/closes its own connection)
for maximum safety with the native C extension. Also respects the
DATABASE_POOL_SIZE setting: if explicitly set >0, QueuePool is used with
the configured pool parameters, matching the behavior of other DB paths.

Fixes #22258
2026-03-06 14:04:10 -06:00
Classic298 1850a985b5 perf: replace O(n²) unshift with O(n) push+reverse in buildMessages (#22280)
Array.unshift() is O(n) per call because it shifts all existing
elements. In a loop building an n-element array, this makes the
total cost O(n²). Replace with push() + reverse() which is O(n)
total. Produces the identical message ordering.
2026-03-06 14:02:57 -06:00
Timothy Jaeryang Baek 339ed1d72e refac 2026-03-06 14:02:05 -06:00
Erhhung Yuan fa1ebfa4fd fix: use same metric description as OTel (#22192) (#22293)
Signed-off-by: Erhhung Yuan <erhhung@gmail.com>
2026-03-06 13:58:25 -06:00
Timothy Jaeryang Baek 0820abbc64 refac 2026-03-06 13:54:55 -06:00
Shirasawa b94e1c9458 fix: Fix memory leaking in Artifacts (#22303) 2026-03-06 13:49:06 -06:00
Classic298 fe58ef69d9 perf(frontend): lazy-load shiki to remove ~5-10MB from initial bundle (#22304)
codeHighlight.ts had a top-level static import of shiki that pulled
the entire highlighter engine (~5-10MB of JavaScript including all
language grammars) into any page that imported the module - even if
only the lightweight isCodeFile() function was used.

Replace the static shiki import with:
- A static set of ~85 common language IDs for synchronous extension
  checks (isCodeFile, extToLang) - no shiki dependency needed
- A dynamic import('shiki') inside highlightCode(), which is already
  async so callers are completely unaffected

The static language set covers all commonly-used file extensions.
Obscure extensions not in the set simply won't be detected by
isCodeFile() (the file still opens fine, just won't show the code
file indicator). Highlighting itself still works for all shiki
languages since the full bundle loads on demand.
2026-03-06 13:47:17 -06:00
Kylapaallikko cc6b51e5ae Update fi-FI translation.json (#22328)
Added and updated translations.
2026-03-06 13:45:56 -06:00
Timothy Jaeryang Baek cd2c315495 refac 2026-03-05 16:13:35 -06:00
Timothy Jaeryang Baek 4b3ed3e802 feat: notebook per-cell execution via open-terminal REST endpoints
- Add notebook API functions (createNotebookSession, executeNotebookCell, stopNotebookSession)
- Create CellEditor component with CodeMirror for cell editing
- Rewrite NotebookView with session-based execution, Run All, Restart, Stop
- Kernel status indicator with tooltips
- Wire baseUrl/apiKey through FilePreview and FileNav
2026-03-05 16:08:11 -06:00
Classic298 8cd2157564 Perf: precompile katex unicode regex (#22196)
* perf: pre-compile KaTeX Unicode regex at module load time

The katexStart() function was creating a new RegExp with Unicode
property escapes (\p{Script=Han}, \p{Script=Hiragana}, etc.) on
every invocation. Unicode property escapes are extremely expensive
to compile as the regex engine must build character class tables
covering tens of thousands of code points.

Since marked calls the start() function at every character position
while scanning source text, this meant hundreds of regex compilations
per marked.lexer() call, and lexer runs ~60 times/sec during streaming.
Profiling showed KaTeX regex consuming 87% (320ms/365ms) of total
markdown rendering time.

Changes:
- Pre-compile SURROUNDING_CHARS_REGEX once at module load time
- Use .test() instead of .match() to avoid array allocations
- Fix delimiter search to find earliest match, not last match

* perf: replace katexStart with single-pass character scan

The katexStart() function was the dominant cost in marked's lexer,
consuming 55-58% of total markdown rendering time per profiling.

It was called at every character position by marked and each call:
- Looped through 3-5 delimiters, each doing indexOf() on the full
  remaining source (3-5 x O(n) string scans per call)
- Ran the complex ruleReg regex with Unicode lookaheads for validation
- On failed validation, created substrings and looped again

Replace with a single linear character scan using charCodeAt that:
- Checks only for $ (charCode 36) or backslash (charCode 92)
- Filters backslash hits by next character to avoid false positives
- Preserves the surrounding-character validation
- Returns immediately on first valid candidate
- Lets the tokenizer handle full validation (it already does this)

This reduces start() from O(n * delimiters * retries) to O(n) with
a very small constant factor per call.

* Update katex-extension.ts
2026-03-05 16:02:00 -06:00
Timothy Jaeryang Baek aaa49bdd6d refac 2026-03-05 14:52:50 -06:00
Timothy Jaeryang Baek 8da02c669e refac 2026-03-05 14:47:48 -06:00
Timothy Jaeryang Baek 828656b35f feat: auto-refresh FileNav on write_file, replace_file_content, and run_command
Backend emits terminal events for write_file, replace_file_content,
and run_command. Frontend showFileNavDir subscriber uses startsWith
path matching to smartly refresh only when the event is relevant:
- write_file/replace_file_content: refresh if path is in current view
- run_command: always refresh (uses root '/' which matches everything)
- Also adds copy-to-clipboard button and code preview full-height fix
2026-03-05 14:41:18 -06:00
Timothy Jaeryang Baek 3b97c8d89b refac 2026-03-05 13:55:02 -06:00
Timothy Jaeryang Baek f5ea1ce250 feat: add copy-to-clipboard button next to download in file toolbar 2026-03-05 13:53:19 -06:00
Timothy Jaeryang Baek a181b4a731 feat: add SQLite database browser in FileNav
- New SqliteView component with table tabs, paginated data view
  (100 rows/page), SQL query editor (Cmd+Enter), NULL/BLOB formatting,
  sticky column headers, and dark mode
- Supports .db, .sqlite, .sqlite3, .db3 extensions
- Uses sql.js WASM served locally from /sql.js/sql-wasm.wasm
- Also fixes display_file handling when another file is already open
2026-03-05 13:34:21 -06:00
Timothy Jaeryang Baek 114f709337 refac 2026-03-04 17:14:12 -06:00
Timothy Jaeryang Baek a6fb5a0460 refac 2026-03-04 17:09:02 -06:00
Timothy Jaeryang Baek 7ef181bc13 refac 2026-03-04 16:52:01 -06:00
Timothy Jaeryang Baek 49a2e5bf57 feat: show refresh button when viewing files, not just directories
- Move refresh button out of directory-only block in FileNavToolbar
- When viewing a file, refresh reloads that file's content
- When in directory view, refresh reloads the listing (unchanged)
2026-03-04 16:48:01 -06:00
Classic298 4403c7b6c2 feat: Timeout for event_call events (#22222)
* Update main.py

* Update env.py

* Update main.py

* Update env.py
2026-03-04 16:39:53 -06:00
Timothy Jaeryang Baek b081e33c0a feat: add Jupyter Notebook (.ipynb) preview in FileNav
- New NotebookView component renders markdown cells (marked+DOMPurify),
  code cells (Shiki-highlighted with execution count gutter), and
  outputs (text, HTML tables, base64 images, error tracebacks)
- ANSI escape codes stripped from error output
- Source toggle shows raw JSON
- Dark mode support throughout
2026-03-04 16:14:26 -06:00
Timothy Jaeryang Baek f4c38e6001 feat: add JSON collapsible tree view, SVG rendered preview, and source toggle
- New JsonTreeView component with recursive collapsible nodes,
  auto-expand depth, and GitHub-themed dark mode colors
- JSON/JSONC/JSON5 files show tree view by default, toggle to
  Shiki-highlighted source
- SVG files show rendered preview (DOMPurify-sanitized) by default,
  toggle to Shiki-highlighted XML source
- SVG removed from IMAGE_EXTS to enable text-based preview
- YAML/TOML already covered by Shiki bundled languages
2026-03-04 16:10:15 -06:00
Timothy Jaeryang Baek c40f26946f feat: add Shiki syntax highlighting, video, and audio previews in FileNav
- Add Shiki-powered syntax highlighting for code files with dual
  light/dark themes (github-light/github-dark), line numbers, and
  source/preview toggle
- Add native <video> player for mp4, webm, mov, ogv, avi, mkv
- Add native <audio> player for mp3, wav, ogg, flac, m4a, aac, opus
- New utility: src/lib/utils/codeHighlight.ts with extension-to-lang
  mapping using Shiki's bundled language registry
2026-03-04 16:04:47 -06:00
Timothy Jaeryang Baek 627b063b88 refac 2026-03-04 16:01:24 -06:00
Timothy Jaeryang Baek f962bae983 feat: improve XLSX preview + add code syntax highlighting
XLSX QoL:
- Custom table renderer (excelToTable.ts) with column letters,
  row numbers, right-aligned numbers, empty cell handling
- Monospace font, sticky headers + row nums, cell cursor
- Sheet tabs moved to bottom bar (like PPTX navigation)
- Unified styles between FileNav and FileItemModal

Code highlighting:
- Shiki-based syntax highlighting for code files in FileNav
- Line numbers, dark/light theme support
- Source/Preview toggle for code files
2026-03-04 15:59:55 -06:00
Timothy Jaeryang Baek e08341dab3 enh: ot ports 2026-03-04 15:51:03 -06:00
Timothy Jaeryang Baek 890949abe6 feat: add DOCX/XLSX/PPTX file preview
- DOCX: mammoth converts to semantic HTML (prose preview)
- XLSX: xlsx library extended to FileNav with sheet tabs at bottom
- PPTX: custom canvas renderer produces PNG images per slide
  with panzoom zoom/pan and slide navigation

Changes:
- New: src/lib/utils/pptxToHtml.ts (canvas-based PPTX renderer)
- FileNav.svelte: office format detection, blob download, conversion
- FilePreview.svelte: office rendering branches, sheet tabs, slide viewer
- FileItemModal.svelte: DOCX/PPTX preview tabs
- package.json: added mammoth dependency
2026-03-04 15:50:37 -06:00
Shirasawa 6e43861c0c feat: prioritize in-group members in sorting (#22211) 2026-03-04 15:03:20 -06:00
Eliot GODARD ad275351b6 i18n(fr-FR): complete French translation pass (#22200)
Adds and harmonizes French translations across the entire UI:
- Translate admin pages (Images, connections, models, etc.)
- Harmonize API key/URL field translations
- Fix "successfully" translations consistency
- Add missing translations (feedback, file, model selector)
- Fix typos and improve existing translations
2026-03-04 13:57:30 -06:00
Shirasawa 7d45459a47 fix: keep save button spinner inline (#22227) 2026-03-04 13:56:49 -06:00
Shirasawa 5af24b3ebe fix: Implement archive chat handler in Chat page navbar (#22229) 2026-03-04 13:54:21 -06:00
Shirasawa a36692b4a2 Merge pull request #22231 from ShirasawaSama/patch-10
fix: add missing beautifulsoup4 to backend requirements
2026-03-04 13:53:50 -06:00
Timothy Jaeryang Baek ca2aaf0321 fix: ot terminal 2026-03-02 19:09:13 -06:00
Tim Baek 79f0437980 Merge pull request #22168 from open-webui/dev
0.8.8
2026-03-03 03:32:58 +04:00
Timothy Jaeryang Baek 10daa64d5b chore: format 2026-03-02 17:26:18 -06:00
Timothy Jaeryang Baek e0d4c3ec92 refac 2026-03-02 17:26:01 -06:00
Classic298 65fbbf5e35 fix: grant file access for knowledge attached to shared workspace models (#22151) 2026-03-02 18:08:49 -05:00
Timothy Jaeryang Baek 10baa6e781 chore: format 2026-03-02 17:07:53 -06:00
Timothy Jaeryang Baek 3de14a53c2 chore: format 2026-03-02 17:04:52 -06:00
Classic298 fe5c02331b chore: changelog (#22152)
* changelog: middleware, tool output, chat fix

* changelog: fix chat history pagination

* changelog: add ChatControls reactivity fix for PR #22127

* changelog: reorder 0.8.8 to top, add middleware fix

* changelog: add second commit to chat history pagination fix

* changelog: terminal file moving feature

* changelog: terminal file moving, general improvements, translations

* changelog: ChatControls TypeScript fix

* changelog: terminal, html-preview, file-browser

* changelog: update translations (Irish, Catalan)

* changelog: terminal websocket proxy

* changelog: terminal, tools, direct-connections

* changelog: terminal feature toggle

* changelog: update terminal feature toggle entry

* changelog: terminal, null parameter handling fix
2026-03-02 17:03:51 -06:00
Classic298 d040953c76 fix: omit None-valued query params in execute_tool_server (#22144) 2026-03-02 16:51:15 -06:00
Timothy Jaeryang Baek b5c3395f79 refac 2026-03-02 16:41:32 -06:00
Timothy Jaeryang Baek ed9ab65b5e refac 2026-03-02 15:23:01 -06:00
Timothy Jaeryang Baek 1a2b360d3d refac 2026-03-02 15:01:10 -06:00
Timothy Jaeryang Baek 4f6cb771f1 enh: open terminal 2026-03-02 14:49:02 -06:00
Aleix Dorca 75683e5197 i18n: Update catalan translation.json (#22129) 2026-03-02 13:49:03 -06:00
8ea35e3bb4 i18n: Updated Irish translation (#22132)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-03-02 13:48:26 -06:00
Timothy Jaeryang Baek 44349fb62b refac 2026-03-02 13:27:37 -06:00
Jannik S. fe1941c13a fix: add missing lang="ts" to ChatControls module script (#22131)
The module-level script block uses TypeScript syntax but was missing
the lang="ts" attribute, causing esbuild to fail during vite dev
dependency scanning.
2026-03-02 12:56:35 -06:00
Timothy Jaeryang Baek 933a3bbbd3 refac 2026-03-02 12:49:51 -06:00
Timothy Jaeryang Baek 3909b62ffc enh: file nav html rendering 2026-03-02 12:45:50 -06:00
Shirasawa bec227da30 i18n: improve Chinese translations (#22148) 2026-03-02 12:23:00 -06:00
Timothy Jaeryang Baek 11487d66fc refac 2026-03-02 12:09:49 -06:00
Timothy Jaeryang Baek 395098c6f1 refac 2026-03-02 12:07:55 -06:00
Timothy Jaeryang Baek 72951324df refac 2026-03-02 12:05:19 -06:00
Timothy Jaeryang Baek 0c42cd2c01 enh: ot move 2026-03-02 12:03:23 -06:00
Timothy Jaeryang Baek c701ebe07b refac 2026-03-02 11:29:29 -06:00
Shirasawa b338850cc1 Merge pull request #22127 from ShirasawaSama/patch-49
fix: Fix TypeScript syntax compilation errors
2026-03-02 11:26:58 -06:00
Timothy Jaeryang Baek 64957db7b3 refac 2026-03-02 11:26:33 -06:00
Timothy Jaeryang Baek d7147d6cdd refac 2026-03-02 11:24:15 -06:00
Tim Baek 6137f7cb7e Merge pull request #22121 from open-webui/dev
0.8.7
2026-03-02 05:14:08 +04:00
Timothy Jaeryang Baek 832d0181b6 chore: format 2026-03-01 19:13:14 -06:00
Timothy Jaeryang Baek d1dd449f63 doc: changelog 2026-03-01 19:12:06 -06:00
Timothy Jaeryang Baek 2751a0f0b6 refac 2026-03-01 19:09:10 -06:00
Shirasawa a9e9fe7899 fix: fix memory leaking of ChatControls (#22112) 2026-03-01 19:06:20 -06:00
Tim Baek 702906aee7 Merge pull request #22119 from Algorithm5838/fix/save-temp-chat-params
fix: pass params when saving a temporary chat
2026-03-02 05:06:02 +04:00
Algorithm5838 fe837d80e7 fix: pass params when saving a temporary chat
The system prompt and other chat controls overrides were lost after
saving because `params` wasn't included in the `createNewChat` call.
2026-03-02 01:35:59 +03:00
Tim Baek 860a0b414e Merge pull request #22111 from Algorithm5838/perf/debounce-get-contents
perf: use rAF to debounce getContents() during streaming
2026-03-02 01:28:15 +04:00
Tim Baek 9c9a18d6d4 Merge pull request #21971 from open-webui/dev
0.8.6
2026-03-02 01:03:55 +04:00
Shirasawa 67893b9a57 fix: fix memory leaking in CodeEditor (#22110) 2026-03-01 15:52:20 -05:00
Timothy Jaeryang Baek 2e8c4da17b refac 2026-03-01 14:45:35 -06:00
Timothy Jaeryang Baek ff9f761d65 refac 2026-03-01 14:44:12 -06:00
Algorithm5838 6863ca482c perf: use rAF to debounce getContents() during streaming 2026-03-01 23:42:16 +03:00
Timothy Jaeryang Baek 5645d5bccc refac 2026-03-01 14:38:10 -06:00
Timothy Jaeryang Baek 201b93bfcc refac 2026-03-01 14:18:57 -06:00
Timothy Jaeryang Baek 0c2e4270bc chore: format 2026-03-01 14:10:45 -06:00
Timothy Jaeryang Baek 80ad5fd2d0 refac 2026-03-01 14:06:26 -06:00
Shirasawa 9904566513 fix: fix memory leaking in Chat.svelte (#21962)
* fix: fix memory leaking in Chat.svelte

* chore: remove useless chatIdUnsubscriber var

* fix: fix async tick
2026-03-01 15:04:47 -05:00
Classic298 2054ee0b73 fix: enforce ownership check on user-memory collection queries (#22109)
* fix: enforce ownership check on user-memory collection queries

fix: enforce ownership check on user-memory collection queries

Prevent authenticated users from querying other users' memory
collections via the /query/doc and /query/collection endpoints.
A new _validate_collection_access helper rejects requests for
user-memory-{UUID} collections where the UUID does not match
the requesting user. Admins bypass the check.

* Update retrieval.py

* Update retrieval.py
2026-03-01 15:03:37 -05:00
Timothy Jaeryang Baek 93bab8d822 refac 2026-03-01 13:54:44 -06:00
Timothy Jaeryang Baek 259d5ca596 refac 2026-03-01 13:49:36 -06:00
Classic298 597883a179 perf: use structuredClone and fast-path comparison in UserMessage (#22098)
Same optimization as the merged ResponseMessage PR: replace JSON.parse(JSON.stringify()) with structuredClone and add an O(1) fast-path check on content before falling back to full JSON.stringify comparison.
2026-03-01 14:46:05 -05:00
Classic298 387225eb8b fix: suppress internal path leakage in audio transcription errors (GHSA-vvxm-vxmr-624h) (#22108)
- Use os.path.basename() for filename sanitization instead of fragile blocklist

- Replace ERROR_MESSAGES.DEFAULT(e) with generic error message in both except blocks to prevent CWE-209 information disclosure

- Server-side logging via log.exception(e) is preserved for debugging
2026-03-01 14:44:49 -05:00
Timothy Jaeryang Baek c83a42198d refac 2026-03-01 13:37:31 -06:00
Timothy Jaeryang Baek 2cacc2e649 chore: format 2026-03-01 13:34:09 -06:00
Timothy Jaeryang Baek c9a78e5476 refac 2026-03-01 13:30:36 -06:00
Timothy Jaeryang Baek 2cbba2a28a chore: format 2026-03-01 13:29:06 -06:00
Timothy Jaeryang Baek 62ab30f593 refac 2026-03-01 13:28:32 -06:00
Timothy Jaeryang Baek 0fff2fbcab refac 2026-03-01 13:23:39 -06:00
Timothy Jaeryang Baek fcff9c3afd refac 2026-03-01 13:20:55 -06:00
Timothy Jaeryang Baek d415edcfcd chore: bump 2026-03-01 13:14:20 -06:00
Classic298 5f304e57d2 chore: changelog (#22080)
* changelog: MentionList memory leak fix

* changelog: multi-model responses horizontal scroll fix

* changelog: tool, json, error-handling

* changelog: add notification HTML escaping fix

* changelog: fix chat timestamp i18n

* changelog: terminal, file creation, SBOM

* changelog: terminal file editing

* changelog: terminal, toolbar, file-preview

* changelog: terminal, file refresh, automation

* changelog: model toast notification fix

* changelog: sidebar memory leak fix

* changelog: streaming performance optimizations

* changelog: message building, streaming, performance

* changelog: socket, status, event type optimizations

* changelog: offline mode, embedding model fix

* changelog: performance entries reworded for clarity
2026-03-01 14:12:21 -05:00
Classic298andahxxm 0b851cf55a fix: offline model retrieval, re-raise to disable instead of returning useless fallback (#22106)
Co-authored-by: ahxxm <1286225+ahxxm@users.noreply.github.com>
2026-03-01 13:52:31 -05:00
Timothy Jaeryang BaekandAlgorithm5838 ff86283be0 refac
Co-Authored-By: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
2026-03-01 12:50:24 -06:00
Algorithm5838 e9011113b4 perf: skip redundant object spread in buildMessages (#22086) 2026-03-01 13:46:11 -05:00
Classic298 1b89bee098 perf: add fast-path comparison in MultiResponseMessages (#22100)
Same optimization as ResponseMessage: add O(1) fast-path check on content and done fields before falling back to full JSON.stringify comparison. Avoids expensive serialization when only content changes during streaming.
2026-03-01 13:44:59 -05:00
Classic298 c436e0366c perf: async DB calls, skip intermediate status writes, elif chain in event emitter (#22107)
Three improvements to the socket event emitter hot path (when realtime chat save is enabled):

1. Wrap all synchronous Chats.* DB calls in asyncio.to_thread() to avoid blocking the event loop during streaming. With N concurrent users, sync DB calls serialize all writes and block socket event delivery.

2. Only persist final (done=True) status events to DB. Intermediate statuses (tool calling progress, web search progress, etc.) are ephemeral UI-only data already delivered via socket — writing every one to DB is unnecessary I/O.

3. Convert if/if/if chain to if/elif since event types are mutually exclusive, avoiding unnecessary string comparisons after a match.
2026-03-01 13:43:03 -05:00
Timothy Jaeryang BaekandShirasawa 1db36b5eda refac
Co-Authored-By: Shirasawa <kaguyashirasawa@gmail.com>
2026-03-01 12:38:59 -06:00
Classic298 3569280c0b perf: replace JSON.parse(JSON.stringify()) with structuredClone in Chat.svelte (#22102)
Replace 7 instances of JSON.parse(JSON.stringify()) deep cloning with the native structuredClone API. All are on cold paths (model selection, file preparation, history saving) but structuredClone is ~2x faster and more readable.
2026-03-01 13:37:20 -05:00
Classic298 a0d6c209c3 perf: fast-path token comparison in CodeBlock (#22101)
During streaming, every token change triggers a full JSON.stringify comparison on the code block token object. Add an O(1) fast-path check on token.text and token.raw — the fields that actually change during streaming — before falling back to the expensive JSON.stringify comparison for infrequent structural changes.
2026-03-01 13:37:10 -05:00
Classic298 73617ec7fa perf: fast-path length check in StatusHistory comparison (#22103)
Add O(1) array length check before expensive JSON.stringify comparison. During streaming, status history typically only grows via appends, so a length mismatch catches most updates without serialization.
2026-03-01 13:36:42 -05:00
Classic298 391a4878e6 perf: replace JSON.parse(JSON.stringify()) with structuredClone in layout (#22104)
Replace JSON roundtrip with native structuredClone for tool execution result cloning. Also remove unnecessary JSON roundtrip on a static error object literal that is already a fresh value.
2026-03-01 13:36:16 -05:00
Shirasawa 6d7f21b57b fix: fix memory leaking of SIdebar (#22082) 2026-03-01 13:35:09 -05:00
Peter L Jones fe604a8a9b bugfix: Prevent double toast on single hide/show toggle (#22079) 2026-03-01 13:34:45 -05:00
joaoback a9d8348cf9 i18n(pt-BR): add translations for newly added UI items + consistency pass (#22095)
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.
2026-03-01 13:33:15 -05:00
Classic298 c37c0e3490 Update translation.json (#22096) 2026-03-01 13:33:01 -05:00
Timothy Jaeryang Baek ddedceb7ad refac 2026-03-01 12:32:44 -06:00
Timothy Jaeryang Baek 18865a9fef refac 2026-03-01 12:30:03 -06:00
Timothy Jaeryang Baek 769ef856bc chore: format 2026-03-01 03:05:47 -06:00
Timothy Jaeryang Baek ed1b959bc6 refac 2026-03-01 02:38:45 -06:00
Timothy Jaeryang Baek d2b38127d0 refac 2026-03-01 02:37:21 -06:00
Timothy Jaeryang Baek 3d535db304 refac 2026-03-01 02:29:37 -06:00
Timothy Jaeryang Baek 234306ff57 refac 2026-03-01 02:08:41 -06:00
Timothy Jaeryang Baek ae28e7d245 refac 2026-03-01 00:17:34 -06:00
Shirasawa 39b87d9683 fix: Fix memory leaking in MentionList.svelte (#21965) 2026-02-28 21:48:56 -06:00
Timothy Jaeryang Baek e83f668107 refac 2026-02-28 21:40:13 -06:00
Timothy Jaeryang Baek 7dda8025fc refac 2026-02-28 21:35:32 -06:00
Timothy Jaeryang Baek 1357dc6737 chore: format 2026-02-28 21:28:59 -06:00
Timothy Jaeryang Baek 43c30428a6 refac 2026-02-28 21:16:53 -06:00
Timothy Jaeryang Baek 668bd44485 refac 2026-02-28 20:22:24 -06:00
Timothy Jaeryang Baek a3de0bcc58 refac 2026-02-28 19:22:35 -06:00
Classic298 aed2f69efe chore: Changelog updates (#21791)
* changelog: add 0.8.6 version with general improvements and translations

* changelog: fix version structure - proper 0.8.6 with today's date

* changelog: add Docker SBOM attestation entry

* changelog: RAG template duplication fix

* changelog: add action button priority sorting feature

* changelog: add public/private model filtering entry

* changelog: fix duplicate model execution, RAG template

* changelog: add USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS env var for user sharing control

* changelog: add reporting-endpoints security header entry

* changelog: add default group share permission env var

* changelog: function valve priority fix

* changelog: german, i18n, translations

* changelog: oauth, session, database-fix

* changelog: models, oauth, cache

* changelog: fix web content knowledge base append

* changelog: password manager autofill fix

* changelog: sidebar menu positioning fix

* changelog: tool query optimization, sidebar menu

* changelog: add 0.8.6 entries for security, models, OAuth, RAG, translations

* changelog: user sharing permission enforcement fix

* changelog: user sharing permission links

* changelog: streaming, performance, rendering

* changelog: database migration execution fix

* changelog: open terminal, tool server

* changelog: terminal, tool-server, optimization

* changelog: add Catalan to translation updates

* changelog: streaming, message comparison, optimization

* changelog: math rendering, performance

* changelog: add Tools to Integrations rename entry

* changelog: add Spanish to translation updates

* changelog: tooltip, performance fix

* changelog: messageinput memory leak fix

* changelog: web search domain filter config fix

* changelog: message cloning performance optimization

* changelog: notes, memory leak, stability

* changelog: streaming scroll optimization performance

* changelog: code block UI fix

* changelog: add model create memory leak fix entry

* changelog: add toast notification to bulk model actions

* changelog: add TailwindCSS gray color theme fix

* changelog: streaming, memory leaks, UI fixes, translations, tools to integrations
2026-02-28 18:10:19 -05:00
Classic298 30ae519226 perf: throttle message list rebuild to once per animation frame during streaming (#21885)
Messages.svelte rebuilds the message list by walking the parent chain and creating spread copies on every history.messages change. During streaming, this runs on every token — hundreds of times per second — even though each ResponseMessage already has its own reactive binding for content updates. Throttle the rebuild to once per animation frame (~60Hz) during content-only updates, while keeping immediate rebuilds for structural changes (currentId changes like chat switches, navigation, or new messages). Adds onDestroy cleanup for the pending rAF.
2026-02-28 18:09:43 -05:00
Timothy Jaeryang BaekandNil Puig 499ca282e5 refac
Co-Authored-By: Nil Puig <244631886+npuigm@users.noreply.github.com>
2026-02-28 17:08:41 -06:00
Shirasawa 40d90286b6 I18n: improve Chinese translation (#21980)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-02-28 16:19:59 -05:00
Timothy Jaeryang BaekandIngmar van Hulzen 2d27ef4ece refac
Co-Authored-By: Ingmar van Hulzen <13165062+ingmarvanhulzen@users.noreply.github.com>
2026-02-28 13:46:30 -06:00
Shirasawa e9b5eb6ed3 fix: Fix memory leaking in create model page (#21966) 2026-02-28 14:41:00 -05:00
Timothy Jaeryang Baek 6b462ff121 refac 2026-02-28 13:40:06 -06:00
Timothy Jaeryang Baek c3bac9aa62 refac 2026-02-28 13:30:28 -06:00
Shirasawa f7226333c3 i18n: improve Chinese translation (#21934)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-02-28 14:14:13 -05:00
Algorithm5838 fc5f399573 perf: batch scrollToBottom during streaming via rAF (#21946) 2026-02-28 14:13:48 -05:00
Shirasawa ff8cf80fb5 fix: fix memory leaking of Notes.svelte (#21963) 2026-02-28 14:09:43 -05:00
Algorithm5838 54cefedf53 perf: use structuredClone for message deep copies (#21948) 2026-02-28 14:09:29 -05:00
Timothy Jaeryang Baek 9440d09114 refac 2026-02-28 13:07:10 -06:00
Shirasawa 5bb1c42fa8 fix: Fix memory leaking of MessageInput (#21968) 2026-02-28 14:03:08 -05:00
Shirasawa 242b3f0c01 fix: Fix Tooltip memory leaking and type define (#21969) 2026-02-28 14:01:28 -05:00
Shirasawa 144c0f3d76 fix: fix missing i18n keys (#21932) 2026-02-28 13:56:12 -05:00
_00_ 18401de254 upd:i18n es-ES language update v0.8.5 (#21956)
### upd:i18n  es-ES language update v0.8.5

Added new strings and a couple of corrections
2026-02-28 13:54:16 -05:00
Timothy Jaeryang Baek c71beb0a7d refac 2026-02-28 02:05:22 -06:00
Timothy Jaeryang Baek f5bf2a2ed7 refac 2026-02-28 00:41:10 -06:00
Timothy Jaeryang Baek ab3f03bbd5 refac 2026-02-28 00:40:20 -06:00
Timothy Jaeryang Baek 5ac502e93f refac 2026-02-27 17:24:34 -06:00
Timothy Jaeryang Baek c60b0fa0e3 refac 2026-02-27 17:20:49 -06:00
Timothy Jaeryang Baek 9544a80aa0 refac 2026-02-27 17:14:54 -06:00
Timothy Jaeryang Baek 83b17e2ac8 refac 2026-02-27 17:04:09 -06:00
Timothy Jaeryang Baek 3a6c88ade9 refac 2026-02-27 16:47:36 -06:00
Timothy Jaeryang Baek 3be06132db refac 2026-02-27 16:41:52 -06:00
Timothy Jaeryang Baek bbbcf27dd5 refac 2026-02-27 16:37:53 -06:00
Timothy Jaeryang Baek cfa16e1a37 refac 2026-02-27 16:37:33 -06:00
Timothy Jaeryang Baek f60d386b74 refac 2026-02-27 16:21:27 -06:00
Timothy Jaeryang Baek 0324a1bbdd refac 2026-02-27 16:03:43 -06:00
Timothy Jaeryang Baek a677b212d9 refac 2026-02-27 16:03:12 -06:00
Timothy Jaeryang Baek 179a4ad9ea refac 2026-02-27 16:01:57 -06:00
Timothy Jaeryang Baek 2d82d260cc refac 2026-02-27 16:01:33 -06:00
Timothy Jaeryang Baek e7a9988893 chore: format 2026-02-27 15:59:52 -06:00
Timothy Jaeryang Baek 6b01f96eac refac 2026-02-27 15:56:25 -06:00
Timothy Jaeryang Baek 965f242d16 refac 2026-02-27 15:53:03 -06:00
Timothy Jaeryang Baek 758d8fcf31 refac 2026-02-27 15:51:15 -06:00
Timothy Jaeryang Baek 0f8b339f6d refac 2026-02-27 15:48:55 -06:00
Timothy Jaeryang Baek 5d821d21f3 refac 2026-02-27 14:36:22 -06:00
Timothy Jaeryang Baek d6d9d1c535 refac 2026-02-27 14:36:13 -06:00
Timothy Jaeryang Baek 44ab77b4f5 refac 2026-02-27 14:12:59 -06:00
Timothy Jaeryang Baek 646b64a318 refac 2026-02-27 13:37:03 -06:00
Timothy Jaeryang Baek bbab64b53e refac 2026-02-27 13:36:55 -06:00
Timothy Jaeryang Baek 4731ccb73c refac 2026-02-27 13:30:36 -06:00
Timothy Jaeryang Baek 4737e1f118 feat: open terminal integration 2026-02-27 13:08:59 -06:00
Classic298 7ea6afdf95 perf: cache KaTeX module import as singleton across all renderer instances (#21880)
* perf: cache KaTeX module import as singleton across all renderer instances

KatexRenderer.svelte dynamically imports katex, mhchem, and the CSS on every component mount. When a message contains multiple math expressions, this triggers redundant module resolution for each one. Move the import promise to a module-level singleton using Svelte's context='module' script block so it loads once and is shared across all KatexRenderer instances.

* Update KatexRenderer.svelte
2026-02-26 15:34:42 -06:00
Classic298 4654ecbf1b perf: fast-path comparison in ResponseMessage to skip JSON.stringify during streaming (#21884)
ResponseMessage compared the entire message object via JSON.stringify on every reactive tick to detect changes. During streaming, content changes on every token, making the two O(content_length) JSON.stringify calls always return different results — pure wasted work. Add a fast O(1) comparison on content and done fields first. When either differs (the common streaming case), skip straight to cloning. Only fall through to the expensive JSON.stringify comparison for infrequent changes like sources, annotations, or status updates.
2026-02-26 14:47:32 -06:00
Aleix Dorca 527d36e13a Update catalan translation.json (#21895) 2026-02-26 14:28:17 -06:00
Stefan Weil d7d05a4717 fix(ui): fix some broken links (#21904)
The referenced information was moved to a new location.

Signed-off-by: Stefan Weil <sw@weilnetz.de>
2026-02-26 14:27:57 -06:00
Timothy Jaeryang Baek 419ea1c346 refac 2026-02-26 00:00:01 -06:00
Timothy Jaeryang Baek 59214538bb refac 2026-02-25 20:17:39 -06:00
Timothy Jaeryang Baek eca9b405eb refac 2026-02-25 19:58:50 -06:00
Timothy Jaeryang Baek 58d685eea4 refac 2026-02-25 19:39:24 -06:00
Timothy Jaeryang Baek 44ed941a5d refac 2026-02-25 19:38:00 -06:00
Timothy Jaeryang Baek 46229a93ce refac 2026-02-25 19:32:01 -06:00
Timothy Jaeryang Baek 50eff6a672 refac 2026-02-25 19:14:02 -06:00
Timothy Jaeryang Baek 1cb74b0bf7 refac 2026-02-25 19:06:46 -06:00
Timothy Jaeryang Baek c303388296 refac 2026-02-25 19:02:52 -06:00
Timothy Jaeryang Baek 5a08084899 refac 2026-02-25 19:00:56 -06:00
Timothy Jaeryang Baek b1f292965c refac 2026-02-25 19:00:40 -06:00
Timothy Jaeryang Baek 819ea0d9be refac 2026-02-25 18:30:53 -06:00
Timothy Jaeryang Baek 1f77691b01 refac 2026-02-25 18:16:20 -06:00
Timothy Jaeryang Baek 50e6a19957 refac 2026-02-25 18:06:09 -06:00
Timothy Jaeryang Baek cb0165827f refac 2026-02-25 17:30:28 -06:00
Timothy Jaeryang Baek c5225039ab refac 2026-02-25 17:23:22 -06:00
Timothy Jaeryang Baek f2c3fff278 refac 2026-02-25 17:07:24 -06:00
Timothy Jaeryang Baek 3271a5277c refac 2026-02-25 16:56:32 -06:00
Timothy Jaeryang Baek 8b2160f2f7 refac 2026-02-25 16:13:18 -06:00
Timothy Jaeryang Baek bee13f72ad refac 2026-02-25 15:59:23 -06:00
Timothy Jaeryang Baek 64ff15a536 refac 2026-02-25 15:52:12 -06:00
Timothy Jaeryang Baek 345f3e3559 refac 2026-02-25 15:15:59 -06:00
Timothy Jaeryang Baek 636ab99ad8 feat: experimental open terminal integration 2026-02-25 15:15:53 -06:00
Timothy Jaeryang Baek f0c71e5a6d refac 2026-02-25 15:15:00 -06:00
Timothy Jaeryang Baek 87d33f6e18 refac 2026-02-25 14:52:41 -06:00
Timothy Jaeryang Baek fd91fa433a refac 2026-02-25 14:06:06 -06:00
Timothy Jaeryang BaekandAlgorithm5838 484ba91b07 refac
Co-Authored-By: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
2026-02-25 13:56:28 -06:00
Timothy Jaeryang Baek acb2147024 refac 2026-02-25 13:53:08 -06:00
Timothy Jaeryang Baek ace69bba75 refac 2026-02-25 13:45:50 -06:00
joaobackandTim Baek 5beb37c57c i18n(pt-BR): add translations for newly added UI items + consistency pass (#21776)
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.

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-02-25 13:34:24 -06:00
Timothy Jaeryang Baek 50f95a4f1a refac 2026-02-25 13:17:29 -06:00
G30 39e3f8fb81 fix(sidebar): lock user menu position when sidebar is resized (#21853)
Use align="start" (left-anchor) instead of align="end" (right-anchor) on
the user menu DropdownMenu.Content, combined with avoidCollisions={false}
to prevent Floating UI from auto-flipping back to end-alignment when the
menu's left edge is near the viewport boundary.

Previously, the right edge of the full-width trigger row tracked the
right edge of the sidebar, so resizing the sidebar wider caused the menu
to drift rightward. With start alignment the menu is anchored to the
left edge of the trigger, which is stable regardless of sidebar width.
2026-02-25 13:13:52 -06:00
Algorithm5838 b2413f914a perf: early-return in get_tools() for empty tool_ids (#21873)
Avoids a needless Groups.get_groups_by_member_id() query when
no tools are attached to the request.
2026-02-25 13:13:18 -06:00
Timothy Jaeryang Baek 9dff497abf refac 2026-02-25 13:12:34 -06:00
Classic298 e3f21d6c3b Update SECURITY.md (#21859) 2026-02-25 12:55:20 -06:00
Timothy Jaeryang Baek 184e921930 refac 2026-02-25 03:09:23 -06:00
Timothy Jaeryang BaekandJohannes Fahrenkrug 5ee5093259 refac
Co-Authored-By: Johannes Fahrenkrug <16358+jfahrenkrug@users.noreply.github.com>
2026-02-24 17:23:36 -06:00
Timothy Jaeryang Baek 81781e6495 refac 2026-02-24 17:14:07 -06:00
Classic298 82959cec88 Update oauth_sessions.py (#21794) 2026-02-24 17:05:47 -06:00
Timothy Jaeryang Baek 9478c5e7ac refac 2026-02-24 17:04:07 -06:00
Timothy Jaeryang BaekandPeter L Jones 62e7e0bc09 refac
Co-Authored-By: Peter L Jones <1549463+pljones@users.noreply.github.com>
2026-02-24 16:51:28 -06:00
Classic298 7a16e495dd Update print statement from 'Hello' to 'Goodbye' (#21842) 2026-02-24 16:42:45 -06:00
Timothy Jaeryang Baek 958fbdd5c0 refac 2026-02-24 16:12:02 -06:00
Classic298 5c403fb829 fix: resolve valve priority for actions and filters via class instantiation (#21841)
fix: resolve valve priority for actions and filters via class instantiation

The priority sorting for action buttons and filter execution order
read valve data directly from the database JSON column using
Functions.get_function_valves_by_id(). This returns only explicitly
saved values — when a developer defines priority as a class default
in their Valves definition (e.g. priority: int = 5) without ever
opening the Valves UI to persist it, the database column remains
empty. Every function then resolves to priority 0, and the preceding
set() deduplication produces non-deterministic iteration order that
the stable sort preserves — resulting in random button placement on
every page load.

The fix instantiates the Valves class with database values as keyword
overrides: Valves(**(db_valves or {})). This merges any persisted
overrides onto the code-defined defaults, matching the pattern already
established in the action execution handler, filter processing
pipeline, and tool module initialization. A secondary sort key (the
function ID) ensures fully deterministic ordering even when multiple
functions share the same priority value.

Affected locations:
- get_action_priority in utils/models.py (action button ordering)
- get_priority in utils/filter.py (filter execution ordering)
2026-02-24 15:58:23 -06:00
Timothy Jaeryang Baek 538501c88d refac 2026-02-24 15:19:49 -06:00
Timothy Jaeryang Baek 0b6c92baa7 refac 2026-02-24 14:57:59 -06:00
Timothy Jaeryang Baek 64ec73635b refac 2026-02-24 14:47:28 -06:00
Timothy Jaeryang Baek b36e55cf1f refac 2026-02-24 13:27:48 -06:00
Timothy Jaeryang Baek 2461121637 refac 2026-02-23 18:31:26 -06:00
Timothy Jaeryang Baek e6fe3ba8ef refac 2026-02-23 18:23:47 -06:00
Timothy Jaeryang Baek 0b867590a8 refac 2026-02-23 18:23:34 -06:00
Timothy Jaeryang Baek 3c8d658160 fix: tools_dict issue 2026-02-23 16:25:38 -06:00
Timothy Jaeryang Baek 176f9a7816 refac 2026-02-23 16:01:03 -06:00
Timothy Jaeryang Baek 3d99de6771 enh: access grant level perms 2026-02-23 15:49:05 -06:00
Peter L Jones a52e6c2d57 Filter by public/private (#21797) 2026-02-23 14:09:13 -06:00
Classic298 1808d7fd2f feat: sort action buttons by valve priority (#21790)
feat: sort action buttons by valve priority

Action buttons under assistant messages were rendered in
non-deterministic order due to set() deduplication. They now
respect the priority field from function Valves, sorted ascending
(lower value = appears first, default 0), matching the existing
filter priority mechanism.
2026-02-23 13:52:12 -06:00
Timothy Jaeryang Baek f4a1d99f00 refac 2026-02-23 12:52:46 -06:00
Timothy Jaeryang Baek 8f49725aa5 refac 2026-02-23 12:17:36 -06:00
Timothy Jaeryang Baek febc66ef2b enh: sbom docker gh action 2026-02-23 12:03:56 -06:00
Timothy Jaeryang Baek 3761b3ac28 refac 2026-02-23 11:52:35 -06:00
Jannik S. 140ab270af fix: correct ENABLE_AUDIT_STDOUT stdout filter (#21777) 2026-02-23 11:52:29 -06:00
Tim Baek 6ab452a452 Merge pull request #21785 from EventHorizon-AI/fix/shortcuts-i18n
fix: dictation toggle shortcuts i18n
2026-02-23 21:50:12 +04:00
Tim Baek 8962afd586 Merge pull request #21784 from ShirasawaSama/i18n/improve-chinese-translation
I18n: improve Chinese translation
2026-02-23 21:49:58 +04:00
EntropyYue 22f074cf59 fix: dictation toggle shortcuts i18n 2026-02-23 22:18:34 +08:00
Shirasawa ec4fe4f390 i18n: improve zh-TW translation 2026-02-23 21:55:14 +08:00
Shirasawa 32c68e000b i18n: improve zh-CN translation 2026-02-23 21:48:10 +08:00
Tim Baek 1ac3dd4a89 Merge pull request #21773 from open-webui/dev
0.8.5
2026-02-23 13:26:21 +04:00
Timothy Jaeryang Baek 55c489146c doc: changelog 2026-02-23 03:25:17 -06:00
Timothy Jaeryang Baek ffcf97e3e1 chore: bump 2026-02-23 03:24:33 -06:00
Timothy Jaeryang Baek 95bde946ba refac 2026-02-23 03:22:19 -06:00
Timothy Jaeryang Baek 895c805e62 feat: dictation toggle 2026-02-23 02:54:53 -06:00
Tim Baek 2ed3055c42 Merge pull request #21618 from open-webui/dev
0.8.4
2026-02-23 11:58:08 +04:00
Timothy Jaeryang Baek 1792f668f2 refac 2026-02-23 01:53:58 -06:00
Timothy Jaeryang Baek 1d3d3b2d94 refac 2026-02-23 01:46:08 -06:00
Timothy Jaeryang Baek 9044abf3bb chore: format 2026-02-23 01:40:53 -06:00
Timothy Jaeryang Baek 424dba443c refac 2026-02-23 01:37:06 -06:00
Classic298 aa649bec6b Chore: Changelog updates (#21754)
* changelog: add prompt enable/disable toggle entry

* changelog: fix PostgreSQL workspace cloning

* changelog: MCP SSL verification fix

* changelog: mcp ssl, general improvements, french translations

* changelog: add memory deletion and listing tools for agents

* changelog: add embeddings and proxy timeout fix for PR #21558

* changelog: pip requirements toggle

* changelog: fix commit references for memory and MCP entries

* changelog: scim, parameter handling, rfc7644

* changelog: update iframe sandbox entry for clarity

* changelog: shared chat optimization, translation updates

* changelog: file access control respect fix

* changelog: chat title query optimization, shared chat loading

* changelog: hybrid search fix, Finnish translations

* changelog: message list performance optimization

* changelog: archived chats, pinned chats, loading optimization

* changelog: knowledge-base-import, overwrite-flag, API-enhancement

* changelog: message upsert and tag filtering optimizations

* changelog: batch access grants, notes payload optimization

* changelog: skill import, json support

* changelog: add fix for imported items display issue

* changelog: add Anthropic Messages API proxy support

* changelog: WebSocket race condition fix for collaborative editing

* 📝

* changelog: drag-drop, firefox, overlay fix

* changelog: add multi-device OAuth sessions feature

* changelog: cyclic chat history deadlock fix

* changelog: group search visibility fix

* changelog: model default feature permissions

* changelog: admin groups sorting, notes optimization

* changelog: model selector, virtual scroll, UI fix

* changelog: user menu drag and click fixes

* changelog: rich-ui, auto-scroll, ux

* changelog: enhance Anthropic Messages API proxy with tool call support

* changelog: embedding concurrency, knowledge import

* changelog: add You.com web search provider (#21599)

* changelog: admin analytics toggle

* changelog: console log spam fix

* changelog: fetch URL citation sources

* changelog: message send optimization

* changelog: oauth, group sharing, settings

* changelog: admin nav drag fix (PR #21701)

* changelog: signup race condition, security fix

* changelog: playground, nav, drag

* changelog: group description, sort dropdown

* changelog: add model selector accessibility improvements

* changelog: consolidate accessibility entries for PRs #21705 and #21706

* changelog: tools list performance optimization

* changelog: accessibility, components, wcag

* changelog: button accessibility labels, wcag compliance

* changelog: Firefox avatar overflow fix

* changelog: disabled model cloning prevention fix

* changelog: dark mode select background fix

* changelog: update date to 2026-02-22, consolidate accessibility entries

* changelog: new chat message handling fix

* changelog: accessibility, aria-labels, settings components

* changelog: admin settings, tab navigation

* changelog: scroll, messages, deletion

* changelog: scroll, chat, message fixes

* changelog: model fallback routing and default model selection fixes

* changelog: remove duplicate scroll jumping fix from 0.8.3

* changelog: model visibility badges

* changelog: prompt import fix

* changelog: dropdown menu drag fix

* changelog: add workspace accessibility improvements to UI accessibility entry

* changelog: docker hub integration

* changelog: global model defaults, admin settings

* changelog: text file type detection fix

* changelog: update date to 2026-02-23

* changelog: ollama reasoning effort fix

* changelog: emoji deduplication in Fixed section

* changelog: sql, warning-fix

* changelog: add plaintext tool output display entry

* changelog: json, logging, format

* changelog: RAG template mutation fix for sequential tool calls

* changelog: analytics sorting, ldap authentication

* changelog: API tools, LDAP fields, SQLAlchemy fixes

* changelog: add folder menu fix, event call input masking, analytics sorting, LDAP fix, SQL warning fix

* changelog: add prompt suggestions and banners moved entries

* changelog: add prompt suggestions and banners moved to current version

* changelog: improve prompt suggestions and banners moved entries

* changelog: add hybrid search deduplication fix
2026-02-23 01:29:14 -06:00
G30 a8a3098782 fix(ui): apply select-none to remaining dropdown menus globally to prevent text highlighting (#21763) 2026-02-23 01:22:02 -06:00
G30 238e9da209 fix(ui): prevent highlighting and dragging of text in admin settings menu link (#21761) 2026-02-23 01:21:55 -06:00
G30 49a1b37e5d fix(ui): prevent highlighting of text in chat integrations menu (#21758) 2026-02-23 01:21:46 -06:00
Timothy Jaeryang Baek c035ff7d14 refac 2026-02-22 19:18:25 -06:00
Timothy Jaeryang Baek 2558fe1a3b refac 2026-02-22 19:16:35 -06:00
G30 e7848ec712 feat(ui): update admin users and evaluations sidebars to leverage native anchor tags for robust new-tab link capabilities (#21723) 2026-02-22 19:08:18 -06:00
Timothy Jaeryang Baek 39e5422d93 refac 2026-02-22 18:58:47 -06:00
Timothy Jaeryang Baek f6bd54fb1f refac 2026-02-22 18:55:24 -06:00
Timothy Jaeryang Baek d9fd2a3f30 refac 2026-02-22 18:42:25 -06:00
Timothy Jaeryang Baek 824eeba56c refac 2026-02-22 18:23:53 -06:00
Timothy Jaeryang Baek e61406c825 refac 2026-02-22 18:19:49 -06:00
Timothy Jaeryang Baek 4853ededca refac 2026-02-22 18:05:25 -06:00
G30 8c127a4814 fix(ui): make folder menu text non-highlightable (#21753) 2026-02-22 18:04:22 -06:00
Timothy Jaeryang Baek 6eba27ee9c refac 2026-02-22 18:00:16 -06:00
Timothy Jaeryang Baek 8f0658e64f fix: payload tools handling 2026-02-22 17:58:59 -06:00
Johann Frei 4b3543d3c0 fix(ui): allow empty LDAP Application DN value and password in General setting… (#21742)
* Allow empty LDAP Application DN value and password in General settings form

* fix(ui): use LDAP app_dn, app_dn_password with empty string instead of enforcing non-empty values
2026-02-22 17:58:12 -06:00
Classic298 d1b39da911 changelog: yeah (#21575)
* changelog: add prompt enable/disable toggle entry

* changelog: fix PostgreSQL workspace cloning

* changelog: MCP SSL verification fix

* changelog: mcp ssl, general improvements, french translations

* changelog: add memory deletion and listing tools for agents

* changelog: add embeddings and proxy timeout fix for PR #21558

* changelog: pip requirements toggle

* changelog: fix commit references for memory and MCP entries

* changelog: scim, parameter handling, rfc7644

* changelog: update iframe sandbox entry for clarity

* changelog: shared chat optimization, translation updates

* changelog: file access control respect fix

* changelog: chat title query optimization, shared chat loading

* changelog: hybrid search fix, Finnish translations

* changelog: message list performance optimization

* changelog: archived chats, pinned chats, loading optimization

* changelog: knowledge-base-import, overwrite-flag, API-enhancement

* changelog: message upsert and tag filtering optimizations

* changelog: batch access grants, notes payload optimization

* changelog: skill import, json support

* changelog: add fix for imported items display issue

* changelog: add Anthropic Messages API proxy support

* changelog: WebSocket race condition fix for collaborative editing

* 📝

* changelog: drag-drop, firefox, overlay fix

* changelog: add multi-device OAuth sessions feature

* changelog: cyclic chat history deadlock fix

* changelog: group search visibility fix

* changelog: model default feature permissions

* changelog: admin groups sorting, notes optimization

* changelog: model selector, virtual scroll, UI fix

* changelog: user menu drag and click fixes

* changelog: rich-ui, auto-scroll, ux

* changelog: enhance Anthropic Messages API proxy with tool call support

* changelog: embedding concurrency, knowledge import

* changelog: add You.com web search provider (#21599)

* changelog: admin analytics toggle

* changelog: console log spam fix

* changelog: fetch URL citation sources

* changelog: message send optimization

* changelog: oauth, group sharing, settings

* changelog: admin nav drag fix (PR #21701)

* changelog: signup race condition, security fix

* changelog: playground, nav, drag

* changelog: group description, sort dropdown

* changelog: add model selector accessibility improvements

* changelog: consolidate accessibility entries for PRs #21705 and #21706

* changelog: tools list performance optimization

* changelog: accessibility, components, wcag

* changelog: button accessibility labels, wcag compliance

* changelog: Firefox avatar overflow fix

* changelog: disabled model cloning prevention fix

* changelog: dark mode select background fix

* changelog: update date to 2026-02-22, consolidate accessibility entries

* changelog: new chat message handling fix

* changelog: accessibility, aria-labels, settings components

* changelog: admin settings, tab navigation

* changelog: scroll, messages, deletion

* changelog: scroll, chat, message fixes

* changelog: model fallback routing and default model selection fixes

* changelog: remove duplicate scroll jumping fix from 0.8.3

* changelog: model visibility badges

* changelog: prompt import fix

* changelog: dropdown menu drag fix

* changelog: add workspace accessibility improvements to UI accessibility entry

* changelog: docker hub integration

* changelog: global model defaults, admin settings

* changelog: text file type detection fix

* changelog: update date to 2026-02-23

* changelog: ollama reasoning effort fix

* changelog: emoji deduplication in Fixed section

* changelog: sql, warning-fix

* changelog: add plaintext tool output display entry
2026-02-22 17:56:25 -06:00
Timothy Jaeryang Baek 053a33631f refac 2026-02-22 17:55:08 -06:00
Timothy Jaeryang Baek becac2b2b7 refac 2026-02-22 17:51:08 -06:00
Timothy Jaeryang Baek 342aa84bbe refac 2026-02-22 17:51:03 -06:00
Andrei Efanov 9e81e1dda1 feat: add LOG_FORMAT=json for structured JSON logging (#21747)
* feat: add LOG_FORMAT env var with JSON formatter for early logging

Introduce LOG_FORMAT environment variable (set to "json" to enable).
When active, logging.basicConfig() uses a JSONFormatter that outputs
single-line JSON objects with fields: ts, level, msg, caller, error,
stacktrace. This covers all log messages emitted during module imports
before Loguru's start_logger() takes over.

* feat: add JSON sink for Loguru when LOG_FORMAT=json

Add _json_sink() as a Loguru sink function that writes single-line JSON
to stdout. In start_logger(), conditionally use the JSON sink instead of
the plain-text stdout_format when LOG_FORMAT is set to "json".

* feat: suppress ASCII banner and fix alembic logging in JSON mode

- Wrap the ASCII art banner print in main.py with a LOG_FORMAT != "json"
  guard so JSON output stays machine-parseable.
- Skip alembic's fileConfig() call in migrations/env.py when
  LOG_FORMAT=json to prevent it from replacing the JSON log handlers
  installed during early startup.
2026-02-22 17:40:17 -06:00
Timothy Jaeryang Baek 3ad2ea6f28 refac 2026-02-22 17:38:22 -06:00
Timothy Jaeryang Baek bab64c9d52 refac 2026-02-22 17:38:14 -06:00
Timothy Jaeryang Baek 0185f3340d refac 2026-02-22 17:28:01 -06:00
Timothy Jaeryang Baek 1cd26372fb refac 2026-02-22 17:26:59 -06:00
Timothy Jaeryang Baek 0ca2e46ade refac 2026-02-22 17:17:44 -06:00
Timothy Jaeryang Baek 30a13b9b2f refac: ollama str think support 2026-02-22 17:11:50 -06:00
Timothy Jaeryang Baek f651809001 refac 2026-02-22 17:05:39 -06:00
Timothy Jaeryang Baek c341f97cfe feat: default model metadata & params 2026-02-22 16:54:34 -06:00
Timothy Jaeryang Baek 32aabe6bae refac 2026-02-22 16:18:32 -06:00
Timothy Jaeryang Baek 3c54863414 refac 2026-02-22 15:41:10 -06:00
Timothy Jaeryang Baek ad9fbfc1af refac 2026-02-22 15:35:16 -06:00
Timothy Jaeryang Baek 29217cb430 refac 2026-02-22 15:34:28 -06:00
G30 c0096b2a53 fix: explicitly disable dragging and text selection inside dropdown menus (#21713)
* fix(ui): remove select-none from move and pdf menu items to allow highlighting

* fix(ui): explicitly disable dragging and text selection inside dropdown menus globally
2026-02-22 15:30:43 -06:00
Classic298 5b9efeef4d fix(a11y): add aria-labels and structural elements to workspace components (#21719) 2026-02-22 15:24:33 -06:00
Classic298 e0087acfb4 fix: model fallback routing for all model types and default model selection (#21736)
fix: model fallback routing for all model types and default model selection

Backend: When ENABLE_CUSTOM_MODEL_FALLBACK is active and a custom model's
base model is unavailable, the fallback now swaps the model and form data
to the configured default model directly. This ensures routing uses the
fallback model's type (pipe, Ollama, or OpenAI) instead of the original
model's type, which previously caused "Model not found" errors when the
fallback was a different backend type.

Frontend: Fixed default model selection in new chat initialization where
the admin-configured default models were always overwritten by the first
available model. The first-available fallback now only triggers when the
configured defaults don't resolve to valid available models.
2026-02-22 15:24:14 -06:00
Timothy Jaeryang BaekandClassic298 1f474187a7 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-02-22 15:22:53 -06:00
Classic298 2beeeb90c2 fix(a11y): add aria-labels to chat message components (#21708)
Add aria-labels, aria-expanded, and semantic improvements to:
- RateComment: close button, rating scale, feedback textarea
- Citations: toggle button with count, source item buttons
- Source/SourceToken: contextual aria-labels for citation buttons
- StatusHistory: toggle button with expanded state
- WebSearchResults: descriptive favicon alt text
- FollowUps: convert div to button element
- RegenerateMenu: submit suggestion button
- FloatingButtons: action buttons, input field, submit button
- CitationModal: close button

WCAG: 4.1.2 (Name, Role, Value), 2.1.1 (Keyboard), 1.1.1 (Non-text Content)
2026-02-22 14:36:42 -06:00
G30 d016cc5771 feat: convert admin settings menu tabs to native anchor tags for new-tab support (#21721)
* docs: generate PR template for navigation drag glitch fixes

* feat(ui): convert admin settings menu tabs to native anchor tags for new-tab support
2026-02-22 14:34:06 -06:00
Classic298 16e567df57 fix(a11y): enhance accessibility for chat settings components (#21715)
This commit adds aria-labels to the text inputs and textareas that previously lacked them, applies role=switch to inputs, and adds accessible titles to floating quick actions.
2026-02-22 14:33:07 -06:00
Classic298 1542dad51a fix(a11y): enhance accessibility for admin user components (#21717)
This commit adds aria-labels to the search inputs, select fields, action buttons, and close buttons on modals across the admin users layout and the site changelog modal for improved screen reader support.
2026-02-22 14:32:49 -06:00
Classic298 2ef55972ff fix: reset taskIds and messageQueue on new chat (#21731)
fix: reset taskIds and messageQueue on new chat

Fixes a bug where clicking "New Chat" after sending a message would
silently drop subsequent messages. The initNewChat function reset most
chat state but did not clear taskIds or messageQueue, causing
submitPrompt to queue messages indefinitely instead of sending them.
2026-02-22 14:30:44 -06:00
G30 75c5d9b179 fix(ui): hide clone button in model menu for models disabled by admins (#21724) 2026-02-22 14:26:33 -06:00
G30 713fe1afa7 fix(ui): prevent avatar alt-text overlap on failed image loads (#21730) 2026-02-22 14:24:16 -06:00
G30 f95cff0895 fix(ui): replace static dropdown backgrounds with transparent mapping (#21728) 2026-02-22 14:23:34 -06:00
Classic298 a0dbd41551 fix(a11y): improve accessibility of top-level auth and onboarding components (#21710)
Adds critical accessibility fixes across various app components:
- auth/+page: provide alt text for logo, turn on screenReader support for password input, add aria-required, hide decorative SVGs from AT
- AppSidebar: wrap navigation icons in a <nav> structure, provide ARIA labels for Home and Chat icons
- s/[id]/+page: convert structural divs into semantically accurate h1 heading and time element, wrap message display in main region
- OnBoarding: replace flawed aria-labelledby with direct aria-label on start button
- NotificationToast: provide role='status' and aria-live='polite' for proper screen reader broadcasting
- ChangelogModal: add required heading semantics for structure
- AddFilesPlaceholder: provide heading element role for standalone text content
- ImportModal: provide aria-label for close button

Addresses WCAG 4.1.3, 1.1.1, 3.3.2, and 1.3.1.
2026-02-22 14:18:53 -06:00
Classic298 bf0fb1c449 fix(a11y): add aria-labels to chat core components (#21709)
Add aria-labels to close, back, and action buttons across:
- Controls/Controls.svelte: close chat controls button
- ChatControls/Embeds.svelte: close embed button
- Overview/Node.svelte: favorite toggle button
- Overview/View.svelte: back and close overview buttons
- ShortcutsModal.svelte: close button
- ShareChatModal.svelte: close button
- ToolServersModal.svelte: close button
- Placeholder/FolderTitle.svelte: folder icon picker, folder options menu

WCAG: 4.1.2 (Name, Role, Value)
2026-02-22 14:18:19 -06:00
Shirasawa 7043751ca4 I18n: improve Chinese translation (#21741)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-02-22 14:15:08 -06:00
Classic298 2d5ebf962a Update README.md (#21735) 2026-02-22 14:14:56 -06:00
Timothy Jaeryang Baek b48594a166 refac 2026-02-21 16:27:25 -06:00
Classic298 74e771fec6 fix(a11y): add aria-hidden to all decorative SVG icon components (#21705)
Add aria-hidden='true' to 112 SVG icon components in src/lib/components/icons/ that were missing this attribute. Decorative icons that convey no semantic meaning should be hidden from the accessibility tree to prevent screen readers from attempting to read meaningless SVG markup (WCAG 1.1.1 Non-text Content, WCAG 4.1.2 Name, Role, Value).

The remaining 60 icon files already had aria-hidden='true' set. All 172 icon components now consistently declare aria-hidden='true' on their root svg element.
2026-02-21 16:14:27 -06:00
Classic298 08f1c823ad fix(a11y): improve model selector accessibility with proper listbox/option pattern (#21706)
- Replace incorrect aria-roledescription='model-item' with role='option' and aria-selected on ModelItem.svelte. The previous attribute was not a valid ARIA role description and provided no useful information to screen readers.

- Add contextual aria-label to each model item button (e.g. 'Select GPT-4 model') instead of just the raw model name, making the action clear to screen reader users.

- Add role='listbox' and aria-label='Available models' to the scrollable model list container in Selector.svelte so screen readers announce the container's purpose and navigate items correctly.

- Make the model selector trigger button's aria-label dynamic: it now announces 'Selected model: GPT-4' when a model is selected, falling back to 'Select a model' when nothing is selected.

- Add aria-label to the eject (unload) button in ModelItem.svelte so screen readers announce its purpose.

- Add aria-label to the cancel download button in Selector.svelte with the specific model name being canceled.

- Improve model profile image alt text from generic 'Model' to contextual '{{modelName}} profile image'.
2026-02-21 16:14:09 -06:00
Timothy Jaeryang Baek b559606387 refac 2026-02-21 16:02:45 -06:00
Timothy Jaeryang Baek 914c7ba876 refac: groups ui 2026-02-21 16:01:48 -06:00
G30 96ca47ac9f fix(ui): prevent text-selection ghost dragging on playground navigation tabs (#21704) 2026-02-21 15:45:58 -06:00
theeggorchicken a0c82c8e4c fix: race condition in signup allows multiple admin accounts (#21631)
The signup_handler function checks has_users() before inserting a new user
and assigns the admin role based on that check. With multiple uvicorn workers,
concurrent signup requests during first-user registration can all observe an
empty user table before any insert completes, causing multiple accounts to
receive the admin role.

Fix: insert with the default role first, then check user count after the
insert. Only promote to admin if this is the only user in the database.
This eliminates the TOCTOU window between the check and the insert.
2026-02-21 15:37:08 -06:00
Timothy Jaeryang Baek 631e30e22d refac 2026-02-21 15:35:34 -06:00
Timothy Jaeryang Baek c114fd6876 refac 2026-02-21 15:33:21 -06:00
G30 c2172e43eb fix(ui): prevent drag-and-drop ghost cursors and text highlighting on admin and workspace navigation tabs (#21701) 2026-02-21 15:23:58 -06:00
Timothy Jaeryang Baek 1ad3656872 refac 2026-02-21 15:22:50 -06:00
Timothy Jaeryang Baek ff7f38d343 refac 2026-02-21 15:20:31 -06:00
Timothy Jaeryang Baek bc482b9cce refac 2026-02-21 15:17:36 -06:00
Timothy Jaeryang Baek 4c94f5d434 refac 2026-02-21 15:16:22 -06:00
Timothy Jaeryang Baek 4b9f821b58 enh: OAUTH_GROUP_DEFAULT_SHARE 2026-02-21 15:08:06 -06:00
Timothy Jaeryang Baek 35598b8017 enh: ENABLE_ADMIN_ANALYTICS 2026-02-21 14:56:19 -06:00
Classic298 45e23c3ad0 perf: eliminate 2 redundant full chat deserialization on every message send (#21596)
* perf: eliminate 2 redundant full chat deserialization on every message send (#162)

Problem:
Every message send triggered get_chat_by_id_and_user_id which loads the
entire Chat row — including the potentially massive JSON blob containing
the full conversation history — even when the caller only needed a
simple yes/no ownership check or a single column value.

Two call sites in the message-send hot path were doing this:

1. main.py ownership verification: loaded the entire chat object including
   all message history JSON, then checked `if chat is None`. The JSON blob
   was immediately discarded — only the existence of the row mattered.

2. middleware.py folder check: loaded the entire chat object including all
   message history JSON, then read only `chat.folder_id` — a plain column
   on the chat table that requires zero JSON parsing.

Fix:
- Added `chat_exists_by_id_and_user_id()`: uses SQL EXISTS subquery which
  returns a boolean without loading any row data. The database can satisfy
  this from the primary key index alone.

- Added `get_chat_folder_id()`: queries only the `folder_id` column via
  `db.query(Chat.folder_id)`, which tells SQLAlchemy to SELECT only that
  single column instead of the entire row.

Both new methods preserve the same error handling semantics (return
False/None on exception) and user_id filtering (ownership check) as
the original get_chat_by_id_and_user_id.

Impact:
- Best case (typical): eliminates deserializing 2 full chat JSON blobs per
  message send. For long conversations (hundreds of messages with tool
  calls, images, file attachments), this blob can be multiple megabytes.
- Worst case: no regression — the new queries are strictly cheaper than
  the old ones (less data transferred, less Python object construction,
  no Pydantic model_validate overhead).
- The 3 remaining full chat loads in process_chat_payload (load_messages_from_db,
  add_file_context, chat_image_generation_handler) are left untouched as
  they genuinely need the full history and require separate analysis.

* Address maintainer feedback: rename method and inline call (#166)

- Rename chat_exists_by_id_and_user_id -> is_chat_owner
- Remove intermediate chat_owned variable; call is_chat_owner directly in if condition
2026-02-21 14:53:31 -06:00
lazariv 5759917f54 feat: Adding You.com as a web search provider (#21599)
* Add ydc.py provider implementation

* Add PersistentConfig entry for you.com

* Add Youcom search function import

* Update you.com configuration

* Add you.com as a web search engine option in frontend

* Add YOUCOM_API_KEY to main.py
2026-02-21 14:51:56 -06:00
Classic298 d247adb60c feat: add citation sources for fetch_url tool results (#21669)
feat: add citation sources for fetch_url tool results

URL fetches now produce clickable citation sources in the UI, matching
the existing behavior of search_web and knowledge file tools. When a
model calls fetch_url during native tool calling, the fetched URL
appears as a citable source with a content preview, giving users full
transparency into what pages the model referenced.
2026-02-21 14:49:19 -06:00
G30 8c713a171d fix(backend): catch 404 http exceptions before generalized exception block in files router (#21687) 2026-02-21 14:48:51 -06:00
Timothy Jaeryang Baek 7e42d727e8 refac 2026-02-21 14:39:28 -06:00
Classic298 9f7dd31e12 feat: scroll to rich ui once rendered (#21698)
* Update Chat.svelte

* Update Chat.svelte
2026-02-21 14:35:32 -06:00
Timothy Jaeryang Baek 5d4547f934 enh: RAG_EMBEDDING_CONCURRENT_REQUESTS 2026-02-21 14:33:48 -06:00
G30 5522b91c32 fix(ui): align profile dropdown items and prevent phantom synthetic drag clicks (#21699) 2026-02-21 14:31:35 -06:00
Timothy Jaeryang Baek 3242dad8ae refac 2026-02-21 14:29:40 -06:00
G30 6d8a6e6d8b fix(model-selector): resolve virtual scroll bug when typing quickly (#21659) 2026-02-21 14:22:50 -06:00
Timothy Jaeryang Baek 8265422ba0 refac 2026-02-21 14:22:20 -06:00
Timothy Jaeryang Baek 10c13b686c refac 2026-02-21 14:19:28 -06:00
Classic298 b1dc58ddb7 feat: add sortable columns to groups admin panel (#21692)
* feat: add sortable columns to groups admin panel

Make the Group and Users column headers in the admin groups list clickable to sort groups alphabetically by name or numerically by member count. Clicking a column toggles ascending/descending order, indicated by a chevron icon. When no sort is active, the default API order (by updated_at) is preserved.

* Update Groups.svelte

* Update Groups.svelte
2026-02-21 14:18:37 -06:00
Timothy Jaeryang Baek a9312d2537 refac 2026-02-21 14:15:32 -06:00
Classic298 4228bf71c4 fix: gate model default features on global config and user permissions (#21690)
fix: gate model default features on global config and user permissions

If you disabled code interpreter globally and in user permissions but
enabled it as a default feature on a model, the code interpreter pill
still appeared in the chat input. Same issue for web search and image
generation.

The setDefaults function in Chat.svelte activated model default features
based solely on the model's capability flag, ignoring whether the feature
was globally enabled or allowed by user permissions. Added the same
global config and user permission checks already used by the integrations
menu visibility and the features object sent to the backend.
2026-02-21 13:54:47 -06:00
Classic298 ac620118c1 fix group search (#21691) 2026-02-21 13:53:47 -06:00
G30 d650c987ec fix: resolve backend execution deadlock when syncing stats with cyclic chat history (#21681) 2026-02-20 23:04:36 -05:00
Timothy Jaeryang Baek 092a358b3c refac 2026-02-20 16:55:06 -06:00
Timothy Jaeryang Baek ae05586fda refac: oauth session management 2026-02-20 16:49:43 -06:00
G30 f5e5632afc fix(chat): prevent stuck drop overlay when dragging outside window in firefox (#21664) 2026-02-20 14:01:24 -06:00
Timothy Jaeryang BaekandLeandro Ygor Loli 2a804541e0 refac
Co-Authored-By: Leandro Ygor Loli <77518998+leandroyloli@users.noreply.github.com>
2026-02-19 16:57:32 -06:00
Timothy Jaeryang Baek 8c485b260f refac 2026-02-19 16:53:21 -06:00
Classic298andTim Baek d664922feb Avoid loading full chat JSON blob for pinned/archived/shared list endpoints (#21591)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-02-19 16:48:23 -06:00
fchevallieratecna 9950cc8c28 fix(i18n): correct French typo "Analtique" → "Analytique" (#21512) 2026-02-19 16:42:57 -06:00
Classic298 3db6d49e57 Query title column directly in get_chat_title_by_id instead of loading full chat (#157) (#21590)
Previously loaded the entire ChatModel (including the full conversation JSON
blob) just to extract the title string. Now queries only the Chat.title
column directly, which is already a top-level DB column.
2026-02-19 16:41:46 -06:00
VasilyLebedev123andVasily Lebedev 6d67ac371d fix: correct unpacking order of distances, documents, and metadatas in hybrid search query (#21562)
Co-authored-by: Vasily Lebedev <Vasily.Lebedev@sapowernetworks.com.au>
2026-02-19 16:38:40 -06:00
Classic298andJordan 326599b8db Fix O(n²) performance in get_message_list by replacing insert(0) with append+reverse (#21588)
Co-authored-by: Jordan <CenteredAxis@users.noreply.github.com>
2026-02-19 16:38:01 -06:00
Classic298 c5c31ab769 fix: respect BYPASS_ADMIN_ACCESS_CONTROL in file list/search endpoints (#21595) 2026-02-19 16:36:48 -06:00
Kylapaallikko 43eb2351d2 Update fi-FI translation.json (#21538)
Added missing translations.
2026-02-19 16:36:32 -06:00
Timothy Jaeryang Baek 0a700aafe4 refac 2026-02-19 16:32:41 -06:00
Timothy Jaeryang Baek 91a0301c9e refac 2026-02-19 16:29:19 -06:00
Timothy Jaeryang Baek 6ac593209c refac 2026-02-19 16:09:54 -06:00
Shirasawa 12bea8cd88 i18n: improve Chinese translation (#21530) 2026-02-19 16:06:23 -06:00
joaoback 1dfe546b6b i18n: pt-BR - add translations for newly added UI items + consistency pass (#21527)
Translate all remaining untranslated strings to Brazilian Portuguese (pt-BR)

Translated ~100 previously untranslated entries (empty "" values) across the Open WebUI i18n JSON file. Changes include:

Translated UI labels, form fields, tooltips, and error messages
Kept brand/product names unchanged where appropriate (e.g., Bing, Brave, Gemini, OpenAI, YouTube)
Translated technical terms with context-appropriate Brazilian Portuguese equivalents (e.g., "Timeout" → "Tempo limite", "Config" → "Configuração", "Endpoint URL" → "URL do Endpoint")
Added translations for search engine integrations (Kagi, Perplexity, SerpApi, Tavily, Yacy, Yandex, etc.)
Translated plural forms for source retrieval messages (sources_one, sources_many, sources_other)
No untranslated entries remain in the file.
2026-02-19 16:06:08 -06:00
Classic298and:o ff837031e4 Update iframe sandbox attributes based on settings (#21529)
Co-authored-by: :o <52920416+gg0h@users.noreply.github.com>
2026-02-19 16:05:47 -06:00
Timothy Jaeryang Baek 139f02a9d9 refac 2026-02-19 16:04:41 -06:00
Timothy Jaeryang Baek 4bef69cc63 refac 2026-02-19 16:03:03 -06:00
Timothy Jaeryang Baek 723185c22f refac 2026-02-19 15:59:58 -06:00
Classic298 35763a352c Optimize shared chats list to use column projection (#163) (#21614)
The GET /chats/shared endpoint was loading full Chat rows including
the entire conversation history JSON blob, only to discard it and
return SharedChatResponse (id, title, share_id, timestamps). Now
uses with_entities() to select only the 5 needed columns, avoiding
deserialization of potentially large chat JSON for every shared chat.
2026-02-19 15:50:03 -06:00
Patrick MonteithandClaude Sonnet 4.6 27c76c677a fix: clamp SCIM pagination args instead of rejecting them (#21577)
RFC 7644 §3.4.2.4 specifies that out-of-range pagination values MUST be
clamped, not rejected. The previous implementation used FastAPI Query
constraints (ge=1, le=100) which caused a 422 response for values like
startIndex=0 or count=9999 — violating the spec.

For both /Users and /Groups:
- startIndex < 1 is now treated as 1 (spec: "SHALL be interpreted as 1")
- count < 0 is now treated as 0 (spec: "SHALL be interpreted as 0")
- count > 100 is clamped to the server maximum of 100

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 15:08:42 -06:00
2f1344d619 Update translation.json (#21602)
Typography issue correction

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-02-19 14:15:31 -06:00
Timothy Jaeryang Baek 8bfab327ec refac 2026-02-19 14:14:36 -06:00
Minwoo 'Charlie' Choi 56246324b2 fix: apply AIOHTTP_CLIENT_TIMEOUT to embeddings endpoint (#21558) 2026-02-19 14:13:50 -06:00
Classic298 af5661c2c8 Merge pull request #21485 from Classic298/claude/fix-mcp-ssl-check-0janH
fix: mcp ssl check
2026-02-19 14:08:15 -06:00
Timothy Jaeryang Baek f872a178bc refac 2026-02-19 14:06:24 -06:00
Timothy Jaeryang Baek 3dd44c4f19 refac 2026-02-18 15:23:50 -06:00
Timothy Jaeryang Baek 094ed0b48c fix: prompts delete 2026-02-18 14:58:39 -06:00
Timothy Jaeryang Baek 9b55343509 refac 2026-02-18 14:43:07 -06:00
Timothy Jaeryang Baek 8a7f698e9d refac 2026-02-18 14:42:00 -06:00
Timothy Jaeryang Baek 990c638f6c refac 2026-02-18 14:40:40 -06:00
Timothy Jaeryang Baek a0195cd5ae refac 2026-02-18 14:33:18 -06:00
Timothy Jaeryang Baek e9d852545c refac 2026-02-18 14:24:42 -06:00
Timothy Jaeryang Baek 49c36238d0 refac 2026-02-18 13:54:59 -06:00
Timothy Jaeryang Baek 74988189b8 refac 2026-02-18 13:06:50 -06:00
459 changed files with 25554 additions and 6137 deletions
+3 -1
View File
@@ -88,9 +88,11 @@ This is to ensure large feature PRs are discussed with the community first, befo
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
+8 -19
View File
@@ -27,28 +27,17 @@ jobs:
echo "::set-output name=version::$VERSION"
- name: Extract latest CHANGELOG entry
id: changelog
run: |
CHANGELOG_CONTENT=$(awk 'BEGIN {print_section=0;} /^## \[/ {if (print_section == 0) {print_section=1;} else {exit;}} print_section {print;}' CHANGELOG.md)
CHANGELOG_ESCAPED=$(echo "$CHANGELOG_CONTENT" | sed ':a;N;$!ba;s/\n/%0A/g')
echo "Extracted latest release notes from CHANGELOG.md:"
echo -e "$CHANGELOG_CONTENT"
echo "::set-output name=content::$CHANGELOG_ESCAPED"
VERSION="${{ steps.get_version.outputs.version }}"
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
- name: Create GitHub release
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const changelog = `${{ steps.changelog.outputs.content }}`;
const release = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${{ steps.get_version.outputs.version }}`,
name: `v${{ steps.get_version.outputs.version }}`,
body: changelog,
})
console.log(`Created release ${release.data.html_url}`)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.get_version.outputs.version }}" \
--title "v${{ steps.get_version.outputs.version }}" \
--notes-file /tmp/release-notes.md
- name: Upload package to GitHub release
uses: actions/upload-artifact@v4
-64
View File
@@ -1,64 +0,0 @@
name: Deploy to HuggingFace Spaces
on:
push:
branches:
- dev
- main
workflow_dispatch:
jobs:
check-secret:
runs-on: ubuntu-latest
outputs:
token-set: ${{ steps.check-key.outputs.defined }}
steps:
- id: check-key
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
if: "${{ env.HF_TOKEN != '' }}"
run: echo "defined=true" >> $GITHUB_OUTPUT
deploy:
runs-on: ubuntu-latest
needs: [check-secret]
if: needs.check-secret.outputs.token-set == 'true'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
lfs: true
- name: Remove git history
run: rm -rf .git
- name: Prepend YAML front matter to README.md
run: |
echo "---" > temp_readme.md
echo "title: Open WebUI" >> temp_readme.md
echo "emoji: 🐳" >> temp_readme.md
echo "colorFrom: purple" >> temp_readme.md
echo "colorTo: gray" >> temp_readme.md
echo "sdk: docker" >> temp_readme.md
echo "app_port: 8080" >> temp_readme.md
echo "---" >> temp_readme.md
cat README.md >> temp_readme.md
mv temp_readme.md README.md
- name: Configure git
run: |
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Set up Git and push to Space
run: |
git init --initial-branch=main
git lfs install
git lfs track "*.ttf"
git lfs track "*.jpg"
rm demo.png
rm banner.png
git add .
git commit -m "GitHub deploy: ${{ github.sha }}"
git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main
+111
View File
@@ -95,6 +95,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
@@ -199,6 +200,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
@@ -304,6 +306,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
@@ -407,6 +410,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_OLLAMA=true
@@ -509,6 +513,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_SLIM=true
@@ -804,3 +809,109 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
copy-to-dockerhub:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# For version tags: copy version tag and major.minor tag
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
# For main branch
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
# e.g. latest-cuda -> also tag as just "cuda"
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
# Determine the source tag on GHCR
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
# Copy each destination tag
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"
+280
View File
@@ -5,6 +5,286 @@ 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.8.9] - 2026-03-07
### Added
- ▶️ **Open Terminal notebook cell execution.** Users can now run Jupyter Notebook code cells directly in the Open Terminal file navigator, execute entire notebooks with a single click, edit and modify cells before running, and control the kernel - bringing full interactive notebook execution to the browser. [Commit](https://github.com/open-webui/open-webui/commit/4b3ed3e802d6f2ec8ee7caf358af810b7d09f789)
- 🗃️ **Open Terminal SQLite browser.** Users can now browse SQLite database files directly in the Open Terminal file navigator, viewing tables and running queries without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/a181b4a731a9ec7856be08d0b045a454d1341cf4)
- 📉 **Open Terminal Mermaid diagram rendering.** Markdown files with Mermaid code blocks are now rendered as diagrams directly in the Open Terminal file navigator, making it easier to visualize flowcharts and other diagrams. [Commit](https://github.com/open-webui/open-webui/commit/aaa49bdd6d6e5c10e8be554039d3cac673008fc2)
- 📓 **Open Terminal Jupyter Notebook previews.** Users can now preview Jupyter Notebook files directly in the Open Terminal file navigator, making it easier to view notebook content without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/b081e33c0a37585a1ee60b6e0e1ea03457f1e5f4)
- 🔃 **Open Terminal auto-refresh.** The Open Terminal file navigator now automatically refreshes when the model writes or modifies files, keeping the view in sync without manual refresh. [Commit](https://github.com/open-webui/open-webui/commit/828656b35f04bf486609183799cf8aa2e9850a76)
- 📎 **Open Terminal file copy button.** Users can now copy file contents directly to clipboard in the Open Terminal file navigator with a single click, making it easier to quickly grab file content without downloading. [Commit](https://github.com/open-webui/open-webui/commit/f5ea1ce250cb02fbc583c6cb3f52a923912d0178)
- 💻 **Code syntax highlighting and XLSX improvements in Open Terminal.** Code files now display with syntax highlighting in the Open Terminal file navigator, and XLSX spreadsheets now show column headers and row numbers for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/f962bae98306ea9264967b78b803397f4821f9b0)
- 🌳 **Open Terminal JSON tree view.** JSON, JSONC, JSONL, and JSON5 files now display as interactive collapsible tree views in the Open Terminal file navigator, and SVG files render as preview images with syntax highlighting support. [Commit](https://github.com/open-webui/open-webui/commit/f4c38e6001dd9d4853ed923e0bc5e790c4fd9941)
- 🛜 **Open Terminal port viewing.** Users can now view listening ports in the Open Terminal file navigator and open proxy connections to them directly from the UI. [Commit](https://github.com/open-webui/open-webui/commit/e08341dab3bb10e26a64eb44cbebd2d507087b03)
- 🎬 **Open Terminal video previews.** Users can now preview video and audio files directly in the Open Terminal file navigator, making it easier to view media without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/c40f26946f2eaeb1587a1f8b0c643b4a5121fc06)
- ✏️ **Open Terminal HTML editing.** Users can now edit HTML source files in Open Terminal with CodeMirror editor, and the save button is properly hidden in preview mode. [Commit](https://github.com/open-webui/open-webui/commit/7806cd5aef9fb0505b2c642ef70599a403cf14ba)
- 📄 **Open Terminal DOCX preview.** Word documents generated or modified by the AI can now be viewed directly in the file navigator with formatted text, tables, and images rendered inline — no need to download and open in a separate application. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
- 📊 **Open Terminal XLSX preview.** Excel spreadsheets in the file navigator now render as interactive tables with column headers and row numbers, making it easy to verify data the AI has generated or processed. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
- 📽️ **Open Terminal PPTX preview.** PowerPoint presentations created by the AI can now be viewed slide-by-slide directly in the file navigator, enabling quick review and iteration without leaving the browser. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
- 📁 **Pyodide file system support.** Users can now upload files for Python code execution in the code interpreter. Uploaded files are available in the `/mnt/uploads/` directory, and code can write output files there for download. The file system persists across code executions within the same session. The code interpreter now also informs models that pip install is not available in the Pyodide environment, guiding them to use alternative approaches with available modules. [#3583](https://github.com/open-webui/open-webui/issues/3583), [Commit](https://github.com/open-webui/open-webui/commit/ce0ca894fea8a2904bc6f832ff186d5fe53dd0b9), [Commit](https://github.com/open-webui/open-webui/commit/989938856fdb4b4afa584ae2d18c88d5be614ae2)
- 🧰 **Tool files access.** Tools can now access the files from the current chat context via the files property in their metadata, enabling more powerful tool integrations. [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0)
- ⚡ **Chat performance.** Chat messages now load and display significantly faster thanks to optimized markdown rendering, eliminating delays when viewing messages with mathematical expressions. [#22196](https://github.com/open-webui/open-webui/pull/22196), [#20878](https://github.com/open-webui/open-webui/discussions/20878)
- 📜 **Message list performance.** Improved message list rendering performance by optimizing array operations, reducing complexity from O(n²) to O(n). [#22280](https://github.com/open-webui/open-webui/pull/22280)
- 🧵 **Streaming markdown performance.** Improved chat responsiveness during streaming by skipping unnecessary markdown re-parsing when the content hasn't changed, eliminating wasted processing during model pauses. [#22183](https://github.com/open-webui/open-webui/pull/22183)
- 🏃 **Chat streaming performance.** Chat streaming is now faster for users not using the voice call feature by skipping unnecessary text parsing that was running on every token. [#22195](https://github.com/open-webui/open-webui/pull/22195)
- 🔖 **Source list performance.** Source lists in chat now render faster thanks to optimized computation that avoids unnecessary recalculations, including moving sourceIds computation to a reactive variable. [#22279](https://github.com/open-webui/open-webui/pull/22279), [Commit](https://github.com/open-webui/open-webui/commit/88af78c), [Commit](https://github.com/open-webui/open-webui/commit/339ed1d72e100c89d8eb26de761dfefe842ef90c)
- 💨 **Chat message tree operations.** Chat message tree operations are now significantly faster, improving overall chat responsiveness. [#22194](https://github.com/open-webui/open-webui/pull/22194)
- 🚀 **Initial page load speed.** Page load is now significantly faster thanks to deferred loading of the syntax highlighting library, reducing the initial JavaScript bundle by several megabytes. [#22304](https://github.com/open-webui/open-webui/pull/22304)
- 🗓️ **Action priority query optimization.** Improved performance of action priority resolution by fixing an N+1 query pattern, reducing database round-trips when loading model actions. [#22301](https://github.com/open-webui/open-webui/pull/22301)
- 🔑 **API key middleware optimization.** The API key restriction middleware was converted to a pure ASGI middleware for improved streaming performance, removing per-chunk call overhead. [#22188](https://github.com/open-webui/open-webui/pull/22188)
- 🏎️ **Model list loading performance.** Model lists now load significantly faster thanks to optimized custom model matching that uses dictionary lookups instead of nested loops. [#22299](https://github.com/open-webui/open-webui/pull/22299), [Commit](https://github.com/open-webui/open-webui/commit/29160741a3defa8768a43100cb6e63c56400279c), [Commit](https://github.com/open-webui/open-webui/commit/03c6caac1fc8625f85cf1164f5a977be8005c1bc)
- ⏱️ **Event call timeout configuration.** Administrators can now configure the WebSocket event call timeout via the WEBSOCKET_EVENT_CALLER_TIMEOUT environment variable, giving users more time to respond to event_call forms instead of timing out after 60 seconds. [#22222](https://github.com/open-webui/open-webui/pull/22222), [#22220](https://github.com/open-webui/open-webui/issues/22220)
- 🔁 **File refresh button visibility.** The refresh button in the chat file navigator now appears when viewing files as well as directories, allowing users to refresh the file view at any time. [Commit](https://github.com/open-webui/open-webui/commit/49a2e5bf573415dae6d4c7e5bd635e499c8de77a)
- 📂 **Nested folders support.** Users can now create subfolders within parent folders, improving organization of chats. A new "Create Subfolder" option is available in the folder context menu. [#22073](https://github.com/open-webui/open-webui/pull/22073), [Commit](https://github.com/open-webui/open-webui/commit/8913f37c3d8fde7dea6d54a550357f1d495b3941)
- 🔔 **Banner loading on navigation.** Admin-configured banners now load when navigating to the homepage, not just on page refresh, ensuring users see new banners immediately. [#22340](https://github.com/open-webui/open-webui/pull/22340), [#22180](https://github.com/open-webui/open-webui/issues/22180)
- 📡 **System metrics via OpenTelemetry.** Administrators can now monitor Python runtime and system metrics including CPU, memory, garbage collection, and thread counts through the existing OpenTelemetry pipeline. [#22265](https://github.com/open-webui/open-webui/pull/22265)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for French, Finnish, Turkish, German, Simplified Chinese, and Traditional Chinese were enhanced and expanded.
- 🔍 **Web search tool guidance.** The web search tool description was updated to encourage direct usage without first checking knowledge bases, making it clearer for users who want to search the web immediately. [#22264](https://github.com/open-webui/open-webui/pull/22264)
### Fixed
- 🗄️ **Migration memory usage.** Database migration on large deployments now processes messages in batches instead of loading everything into memory, preventing out-of-memory errors during upgrades. [#21542](https://github.com/open-webui/open-webui/pull/21542), [#21539](https://github.com/open-webui/open-webui/discussions/21539)
- 🔒 **SQLCipher connection stability.** Fixed a crash that occurred when using database encryption with SQLCipher by changing the default connection pool behavior, ensuring stable operation during multi-threaded operations like user signup. [#22273](https://github.com/open-webui/open-webui/pull/22273), [#22258](https://github.com/open-webui/open-webui/issues/22258)
- 🛑 **Stop sequence error.** Fixed a bug where setting stop sequences on a model caused the chat to fail with a split error, preventing any responses from being returned. The fix handles both string and array formats for stop tokens. [#22251](https://github.com/open-webui/open-webui/issues/22251), [Commit](https://github.com/open-webui/open-webui/commit/c7d1d1e390a79c6c86d4bfe439fd7de6f5fb060f)
- 🔐 **Microsoft OAuth refresh token fix.** Fixed a bug where Microsoft OAuth refresh token requests failed with error AADSTS90009 by adding support for the required scope parameter. Users can now stay logged in reliably with Microsoft OAuth. [#22359](https://github.com/open-webui/open-webui/pull/22359)
- 🛠️ **Parameterless tool calls.** Fixed parameterless tool calls failing during streaming by correcting the default arguments initialization, eliminating unnecessary model retries. [#22189](https://github.com/open-webui/open-webui/pull/22189)
- 🔧 **Tool call streaming fixes.** Fixed two bugs where streaming tool calls failed silently for models like GPT-5: function names were incorrectly duplicated when sent in multiple delta chunks, and arguments containing multiple JSON objects were not properly split. Tools now execute correctly instead of failing without explanation. [#22177](https://github.com/open-webui/open-webui/issues/22177), [Commit](https://github.com/open-webui/open-webui/commit/d7efdcce2b1cdbe1637a469294bf9d52dbacab53), [Commit](https://github.com/open-webui/open-webui/commit/459a60a24240eab33441ed50f4f68cc27e65a037)
- 🔗 **Tool server URL trailing slash.** Fixed tool server connection failures when URLs have trailing slashes by stripping them before path concatenation. Previously, URLs like "http://host:8080/v1/" + "/openapi.json" produced double-slash URLs that some servers rejected. [#22116](https://github.com/open-webui/open-webui/pull/22116), [#21917](https://github.com/open-webui/open-webui/issues/21917)
- 🛡️ **Citation parser error handling.** Fixed crashes when tools return error strings instead of expected data structures by adding type guards to the citation parser. The system now returns an empty source list instead of crashing with AttributeError. [#22118](https://github.com/open-webui/open-webui/pull/22118)
- 🧠 **Artifacts memory leak.** Fixed a memory leak where Svelte store subscriptions in the Artifacts component were not properly cleaned up when the component unmounted, causing memory to accumulate over time. [#22303](https://github.com/open-webui/open-webui/pull/22303)
- ♾️ **Artifacts reactive loop fix.** Fixed an infinite reactive loop in chat when artifacts are present by moving the animation frame logic outside the reactive block, preventing continuous re-rendering and CPU usage. [#22238](https://github.com/open-webui/open-webui/pull/22238), [Commit](https://github.com/open-webui/open-webui/commit/626fcff417afba642f4f71e0498267a21435c524)
- 🔀 **Artifact navigation.** Artifact navigation via arrow buttons now works correctly; the selected artifact is no longer reset when content updates. [#22239](https://github.com/open-webui/open-webui/pull/22239)
- 🧩 **Artifact thinking block fix.** Fixed a bug where HTML preview rendered code blocks inside thinking blocks for certain models like Mistral and Z.ai, causing stray code with ">" symbols to appear before the actual artifact. The fix strips thinking blocks before extracting code for artifact rendering. [#22267](https://github.com/open-webui/open-webui/issues/22267), [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0)
- 💬 **Floating Quick Actions availability.** Fixed an issue where the "Ask" and "Explain" Floating Quick Actions were missing when selecting text in chats that used a model that is no longer available. [#22149](https://github.com/open-webui/open-webui/pull/22149), [#22139](https://github.com/open-webui/open-webui/issues/22139)
- 💡 **Follow-up suggestions.** Fixed follow-up suggestions not appearing by correcting contradictory format instructions in the prompt template, ensuring the LLM returns the correct JSON object format. [#22212](https://github.com/open-webui/open-webui/pull/22212)
- 🔊 **TTS thinking content.** Fixed TTS playback reading think tags instead of skipping them by handling edge cases where code blocks inside thinking content prevented proper tag removal. [#22237](https://github.com/open-webui/open-webui/pull/22237), [#22197](https://github.com/open-webui/open-webui/issues/22197)
- 🎨 **Button spinner alignment.** Button spinners across multiple modals now align correctly and stay on the same line as the button text, fixing layout issues when loading states are displayed. [#22227](https://github.com/open-webui/open-webui/pull/22227)
- 📶 **Terminal keepalive.** Terminal connections now stay active without being closed by idle timeouts from proxies or load balancers, and spurious disconnection messages no longer appear. [Commit](https://github.com/open-webui/open-webui/commit/ca2aaf0321c219d041e92e2c0c842a4e424732ef)
- 📥 **Chat archive handler.** The archive button in the chat navbar now actually archives the chat and refreshes the chat list, instead of doing nothing. [#22229](https://github.com/open-webui/open-webui/pull/22229)
- 🐍 **BeautifulSoup4 dependency.** Added the missing BeautifulSoup4 package to backend requirements, fixing failures when using features that depend on HTML parsing. [#22231](https://github.com/open-webui/open-webui/pull/22231)
- 👥 **Group users default sort.** Group members in the admin panel now sort by last active time by default instead of creation date, making it easier to find active users. [#22211](https://github.com/open-webui/open-webui/pull/22211)
- 🔓 **Tool access permissions.** Users can now change tool and skill access permissions from private to public without errors. [#22325](https://github.com/open-webui/open-webui/pull/22325), [#22324](https://github.com/open-webui/open-webui/issues/22324)
- 🖥️ **Open Terminal permission fix.** Open Terminal is now visible without requiring "Allow Speech to Text" permission, fixing an issue where users without microphone access couldn't access the terminal feature. [#22374](https://github.com/open-webui/open-webui/issues/22374), [Commit](https://github.com/open-webui/open-webui/commit/70a31a9a57bdd0690ac270f31ebd1b46e8fdfa98)
- 📌 **Stale pinned models cleanup.** Pinned models that are deleted or hidden are now automatically unpinned, keeping your pinned models list up to date. [Commit](https://github.com/open-webui/open-webui/commit/af4500e5040c8343d339cd88dd1d2fb6138c7a72)
- 📏 **OpenTelemetry metric descriptions.** Fixed conflicting metric instrument descriptions that caused warnings in the OpenTelemetry collector, resulting in cleaner telemetry logs for administrators. [#22293](https://github.com/open-webui/open-webui/pull/22293)
- 🔢 **Non-streaming token tracking.** Token usage from non-streaming chat responses is now correctly saved to the database, fixing missing token counts in the Admin Panel analytics. Previously, non-streaming responses saved NULL usage data, causing messages to be excluded from token aggregation queries. [#22166](https://github.com/open-webui/open-webui/pull/22166)
- ⌨️ **Inline code typing.** Fixed a bug where typing inline code with backticks incorrectly deleted the character immediately before the opening backtick, so text formatted as inline code now correctly produces the full word instead of missing the last character. [#20417](https://github.com/open-webui/open-webui/issues/20417), [Commit](https://github.com/open-webui/open-webui/commit/e303c3da3b174da9e92a79b174f85ba574ca06ef)
- 📝 **Variable input newlines.** Fixed a bug where variables containing newlines were not displayed correctly in chat messages, and input values from Windows systems are now properly normalized to use standard line endings. [#21447](https://github.com/open-webui/open-webui/issues/21447), [Commit](https://github.com/open-webui/open-webui/commit/7b2f597b30c77ef300d1966e1c6a3edfdb0c465d)
- 📷 **Android photo capture.** Fixed an issue where the first photo taken in chat appeared completely black on some Android devices by using an alternative canvas export method. [#22317](https://github.com/open-webui/open-webui/pull/22317)
- 🪟 **Open Terminal Windows path fix.** Fixed a bug where navigating back to parent directories on Windows added an incorrect leading slash, causing directory loads to fail. Paths are now properly normalized for Windows drive letters. [#22352](https://github.com/open-webui/open-webui/issues/22352), [Commit](https://github.com/open-webui/open-webui/commit/044fd1bd15cae06a5c56a321ca79d8362942f66a)
- 🖼️ **Chat overview profile image sizing.** Fixed a bug where profile images in the chat overview could shrink incorrectly in tight spaces. The images now maintain their proper size with the flex-shrink-0 property. [#22261](https://github.com/open-webui/open-webui/pull/22261)
- 📨 **Queued messages display.** Fixed an issue where queued messages could be cut off or hidden. The queued messages area now scrolls properly when content exceeds the visible area, showing up to 25% of the viewport height. [#22176](https://github.com/open-webui/open-webui/pull/22176)
- 🖌️ **Image generation in temporary chats.** Generated images now display correctly in temporary chat mode when using builtin image generation tools. Previously, images were not shown because the code was overwriting the image list with a null database response. [#22330](https://github.com/open-webui/open-webui/pull/22330), [#22309](https://github.com/open-webui/open-webui/issues/22309)
- 🤖 **Ollama model unload fix.** Fixed a bug where unloading a model from Ollama via the Open WebUI proxy failed with a "Field required" error for the prompt field. The proxy now correctly allows omitting the prompt when using keep_alive: 0 to unload models. [#22260](https://github.com/open-webui/open-webui/issues/22260), [Commit](https://github.com/open-webui/open-webui/commit/95b65ff751f91131b633cb128ff2decdd87c4a85)
- 🏷️ **Banner type dropdown fix.** Fixed a bug where selecting a banner type required two clicks to register, as the first selection was being swallowed due to DOM structure changes. The dropdown now works correctly on the first click. [#22378](https://github.com/open-webui/open-webui/pull/22378)
- 📈 **Analytics URL encoding fix.** Fixed a bug where the Analytics page failed to load data for models with slashes in their ID, such as "anthropic/claude-opus-4.6". The frontend now properly URL-encodes forward slashes, allowing model analytics to load correctly. [#22380](https://github.com/open-webui/open-webui/issues/22380), [#22382](https://github.com/open-webui/open-webui/pull/22382)
- 📋 **Analytics chat list duplicate fix.** Fixed a bug where the Analytics page chat list threw an "each_key_duplicate" Svelte error when chat IDs were duplicated during pagination. The fix adds deterministic ordering to prevent duplicate entries. [#22383](https://github.com/open-webui/open-webui/pull/22383)
- 📂 **Folder knowledge base native tool call fix.** Fixed a bug where folders with attached knowledge bases were querying the knowledge base twice when using native tool call mode. The fix now correctly separates knowledge files from regular attachments, letting the builtin query_knowledge_files tool handle knowledge searches instead of duplicating RAG queries. [#22236](https://github.com/open-webui/open-webui/issues/22236), [Commit](https://github.com/open-webui/open-webui/commit/967b1137dcb7a52615f17d086ee89095bb9b60f3), [Commit](https://github.com/open-webui/open-webui/commit/80b5896b70d07ea868e2010b187430d43c9808f0)
## [0.8.8] - 2026-03-02
### Added
- 📁 **Open Terminal file moving.** Users can now move files and folders between directories in the Open Terminal file browser by dragging and dropping them. [Commit](https://github.com/open-webui/open-webui/commit/0c42cd2c012f9f49816adac897e2b46573b3cb6c), [Commit](https://github.com/open-webui/open-webui/commit/72951324dfeef64e09f4776898d675bc1c44f040), [Commit](https://github.com/open-webui/open-webui/commit/395098c6f1b7499d37ad55145a5931431d3e72e9), [Commit](https://github.com/open-webui/open-webui/commit/11487d66fc1a2dfafbdaa2b7ef939a86caaf3872)
- 📄 **Open Terminal HTML file preview.** Users can now preview HTML files directly in the Open Terminal file browser, with a rendered iframe view and source toggle, enabling iterative AI editing of HTML files. [Commit](https://github.com/open-webui/open-webui/commit/3909b62ffcf49839fa57346ed8487ae759811503), [Commit](https://github.com/open-webui/open-webui/commit/933a3bbbd3f4fc3eeb0ec52c7965e9ac1c4cea39)
- 🌐 **Open Terminal WebSocket proxy.** Added a new WebSocket proxy endpoint for interactive terminal sessions, enabling real-time bidirectional terminal communication with the terminal server. [Commit](https://github.com/open-webui/open-webui/commit/4f6cb771f1afded09aad6199cdb244dd8a6c77a6)
- ⚙️ **Open Terminal feature toggle.** Administrators can now enable or disable the Interactive Terminal feature for Open Terminal via configuration on the terminal server, controlling access to terminal routes. [Commit](https://github.com/open-webui/open-webui/commit/b5c3395f79bcc7ff5bc1d82bb86a60583bb3b5bd)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Simplified Chinese, Traditional Chinese, Irish, and Catalan were enhanced and expanded.
### Fixed
- 🔧 **Middleware variable shadowing.** Fixed a variable shadowing issue in the middleware that could cause incorrect tool output processing during chat. [#22145](https://github.com/open-webui/open-webui/pull/22145)
- ⚡ **ChatControls reactivity fix.** Fixed a Svelte reactivity issue where the active tab state in the ChatControls panel was not properly saved when switching between chats. [#22127](https://github.com/open-webui/open-webui/pull/22127)
- 🔧 **ChatControls TypeScript fix.** Fixed a TypeScript syntax error in ChatControls.svelte where the module script block was missing lang="ts", causing esbuild to fail during vite dev. [#22131](https://github.com/open-webui/open-webui/pull/22131)
- 🔌 **Open Terminal tools for direct connections.** Fixed an issue where Open Terminal tools were not available to the model when the terminal was configured via direct connection settings, ensuring users can now interact with terminal files and operations through the AI. [#22137](https://github.com/open-webui/open-webui/issues/22137)
- 📜 **Chat history pagination.** Fixed an issue where older messages in long chats were not loaded when scrolling to the top. [Commit](https://github.com/open-webui/open-webui/commit/d7147d6cddfd314f0f1be77b15cec406a609ef36), [Commit](https://github.com/open-webui/open-webui/commit/c701ebe07bd152eecb42b0bf6de26071358a5c76)
- 🔧 **Terminal tool null parameter handling.** Fixed a bug where null parameters in terminal tool calls were sent as the string "None" instead of being omitted, causing 422 validation errors from the open-terminal server. [#22124](https://github.com/open-webui/open-webui/issues/22124), [#22144](https://github.com/open-webui/open-webui/pull/22144)
### Changed
## [0.8.7] - 2026-03-01
### Fixed
- 🔒 **Connection access control privacy.** Tool server and terminal connections without explicit access grants are now private (admin-only) by default, fixing a bug where connections configured with no access grants were visible to all users instead of being restricted. [Commit](https://github.com/open-webui/open-webui/commit/2751a0f0b)
- 🧠 **ChatControls memory leak.** The ChatControls panel no longer leaks event listeners, ResizeObserver instances, and media query handlers when navigating between chats, fixing memory accumulation that could degrade performance during extended use. [#22112](https://github.com/open-webui/open-webui/pull/22112)
- 💾 **Temporary chat params preservation.** Model parameters are now correctly saved when creating a temporary chat, ensuring custom settings like temperature and top_p persist across the session. [Commit](https://github.com/open-webui/open-webui/commit/fe837d80e)
- ⚡ **Faster artifact content updates.** Artifact content extraction during streaming is now debounced via requestAnimationFrame, reducing redundant DOM reads and improving CPU efficiency when tokens arrive faster than the browser can paint. [Commit](https://github.com/open-webui/open-webui/commit/6863ca482)
## [0.8.6] - 2026-03-01
### Added
- 🖥️ **Open Terminal integration.** Users can now connect to [Open Terminal](https://github.com/open-webui/open-terminal) instances to browse, read, and upload files directly in chat, with the terminal acting as an always-on tool. File navigation includes folder browsing, image and PDF previews, drag-and-drop uploads, directory creation, and file deletion. The current working directory is automatically injected into tool descriptions for context-aware commands. [Commit](https://github.com/open-webui/open-webui/commit/636ab99ad8e5b71b32dd37ba7c62c32368585b2a), [Commit](https://github.com/open-webui/open-webui/commit/64ff15a5365e2c4122fccab582782669f06ec58d), [Commit](https://github.com/open-webui/open-webui/commit/4737e1f11847d057859ec78892fa89e24cbcd83b)
- 📄 **Terminal file creation.** Users can now create new empty files directly in the Open Terminal file browser, in addition to the existing folder creation functionality. [Commit](https://github.com/open-webui/open-webui/commit/234306ff57c9e24314ff805a60de919632465319)
- ✏️ **Terminal file editing.** Users can now edit text files directly in the Open Terminal file browser, with the ability to save changes back to the terminal. [Commit](https://github.com/open-webui/open-webui/commit/3d535db304bfc6fa09e655f737de8a36c0482868)
- 🛠️ **Terminal file preview toolbar.** The Open Terminal file browser now displays contextual toolbar buttons based on file type, including preview/source toggle for Markdown and CSV files, reset view for images, and improved editing controls for text files. [Commit](https://github.com/open-webui/open-webui/commit/d2b38127d0572006577b85c770607b04782de4f9)
- 🔄 **Terminal file write refresh.** The file browser now automatically refreshes when files are written or modified via the write_file or replace_file_content tools, eliminating the need to manually refresh. [Commit](https://github.com/open-webui/open-webui/commit/18865a9fef1bb154603b7b8af0116a10560e03ac)
- 🛡️ **Docker image SBOM attestation.** Docker images now include a Software Bill of Materials (SBOM) for vulnerability scanning and supply chain security compliance. [#21779](https://github.com/open-webui/open-webui/issues/21779), [Commit](https://github.com/open-webui/open-webui/commit/febc66ef2bb05606b59719e737ac5ad839002977)
- 📡 **Reporting-Endpoints security header.** Administrators can now configure a Reporting-Endpoints header via the REPORTING_ENDPOINTS environment variable to receive CSP violation reports directly, aiding in security policy debugging and hardening. [#21830](https://github.com/open-webui/open-webui/issues/21830)
- 🎯 **Action button priority sorting.** Action buttons under assistant messages now appear in a consistent order based on the priority field from function Valves, allowing developers to control button placement. [#21790](https://github.com/open-webui/open-webui/pull/21790)
- 🏷️ **Public/Private model filtering.** The Admin Settings Model listing now displays Public/Private badges and includes filter options to easily view public or private models. [#21732](https://github.com/open-webui/open-webui/issues/21732), [#21797](https://github.com/open-webui/open-webui/pull/21797)
- 👁️ **Show/Hide all models bulk action.** Administrators can now show or hide all models at once from the Admin Settings Models page Actions menu, making it faster to manage model visibility. Bulk actions now display a single toast notification on success for better user feedback. [#21838](https://github.com/open-webui/open-webui/pull/21838), [#21958](https://github.com/open-webui/open-webui/pull/21958)
- 🔐 **Individual user sharing control.** Administrators can now disable individual user sharing via the USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS environment variable, allowing only group-based sharing when set to false. [#21793](https://github.com/open-webui/open-webui/issues/21793), [Commit](https://github.com/open-webui/open-webui/commit/3d99de67716774af2f95f2e3c8e7cc4879464c71), [Commit](https://github.com/open-webui/open-webui/commit/176f9a781619d836be003d28d53904639cad4128)
- 🔄 **OAuth profile sync on login.** Administrators can now enable automatic synchronization of user profile name and email from OAuth providers on login via the OAUTH_UPDATE_NAME_ON_LOGIN and OAUTH_UPDATE_EMAIL_ON_LOGIN environment variables. [#21787](https://github.com/open-webui/open-webui/pull/21787), [Commit](https://github.com/open-webui/open-webui/commit/9478c5e7ac8254b5f522c006da0c1c49bb282727)
- 👥 **Default group share permission.** Administrators can now configure the default sharing permission for new groups via the DEFAULT_GROUP_SHARE_PERMISSION environment variable, controlling whether anyone, no one, or only members can share to new groups. [Commit](https://github.com/open-webui/open-webui/commit/538501c88da034434bcd1969f15341dbbaf154e4)
- 💨 **Streaming performance.** Chat responses now render more efficiently during streaming, reducing CPU usage and improving responsiveness. [Commit](https://github.com/open-webui/open-webui/commit/484ba91b0777042eb848134f206ef3921f968dea)
- 🧮 **Streaming message comparison.** Chat message updates during streaming are now faster thanks to an optimization that skips expensive comparisons when content changes. [#21884](https://github.com/open-webui/open-webui/pull/21884)
- 🚀 **Streaming scroll optimization.** Chat auto-scroll during streaming is now more efficient by batching scroll operations via requestAnimationFrame, reducing unnecessary layout reflows when tokens arrive faster than the browser can paint. [#21946](https://github.com/open-webui/open-webui/pull/21946)
- 📋 **Message cloning performance.** Chat message cloning during streaming is now more efficient thanks to the use of structuredClone() instead of JSON.parse(JSON.stringify(...)). [#21948](https://github.com/open-webui/open-webui/pull/21948)
- 🎯 **Faster code block rendering.** Chat message updates during streaming are now faster. [#22101](https://github.com/open-webui/open-webui/pull/22101)
- 📊 **Faster status history display.** Chat message updates during streaming are now faster. [#22103](https://github.com/open-webui/open-webui/pull/22103)
- 🛠️ **Faster tool result handling.** Tool execution results are now handled more efficiently, improving streaming performance. [#22104](https://github.com/open-webui/open-webui/pull/22104)
- 💾 **Faster model and file operations.** Model selection, file preparation, and history saving are now faster. [#22102](https://github.com/open-webui/open-webui/pull/22102)
- 🛠️ **Tool server advanced options toggle.** Advanced OpenAPI configuration options in the tool server modal are now hidden by default behind a toggle, simplifying the interface for basic setups. The admin settings tab was also renamed from "Tools" to "Integrations" for clearer organization. [Commit](https://github.com/open-webui/open-webui/commit/f0c71e5a6d971af7322d4245313e5e04620253f0), [Commit](https://github.com/open-webui/open-webui/commit/4731ccb73c4b4bab78fd86fec7b2c231af8cca8b)
- 🔧 **Faster tool loading.** Tool access control now skips an unnecessary database query when no tools are attached to the request, slightly improving performance. [#21873](https://github.com/open-webui/open-webui/pull/21873)
- ➗ **Faster math rendering.** Mathematical notation now renders more efficiently, improving responsiveness when displaying equations in chat. [#21880](https://github.com/open-webui/open-webui/pull/21880)
- 🏎️ **Faster message list updates.** The chat message list now rebuilds at most once per animation frame during streaming, reducing CPU overhead. [#21885](https://github.com/open-webui/open-webui/pull/21885)
- 📋 **Faster message rendering.** Chat message rendering is now more efficient during streaming. [#22086](https://github.com/open-webui/open-webui/pull/22086)
- 🗄️ **Faster real-time chat updates.** Chat responses now process faster with improved handling for concurrent users. [#22087](https://github.com/open-webui/open-webui/pull/22087)
- 📝 **Faster status persistence.** Only final status updates are now saved to the database during streaming, reducing unnecessary writes. [#22085](https://github.com/open-webui/open-webui/pull/22085)
- 🔄 **Faster event matching.** Event handling in the socket handler is now more efficient. [Commit](https://github.com/open-webui/open-webui/commit/ff86283be0479ccb86b639926b2b67ccbbe78746)
- 🔀 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for German, Portuguese (Brazil), Simplified Chinese, Traditional Chinese, Catalan, and Spanish were enhanced and expanded.
### Fixed
- 🗄️ **Database migration execution.** Database migrations now run correctly on startup, fixing a circular import issue that caused schema updates to fail silently. [#21848](https://github.com/open-webui/open-webui/pull/21848), [Commit](https://github.com/open-webui/open-webui/commit/87d33f6e18196876603eee7d1bf8e4977c7fa9c1)
- 🔔 **Notification HTML escaping.** Notification messages now properly escape HTML content, matching the behavior in chat messages and ensuring consistent rendering across the interface. [#21860](https://github.com/open-webui/open-webui/issues/21860), [Commit](https://github.com/open-webui/open-webui/commit/e83f668107723fa90ba0efa76c340c8338f45431)
- 🛠️ **Tool call JSON error handling.** Chat no longer crashes when models generate malformed JSON in tool call arguments; instead, a descriptive error message is returned to the model for retry. [#21984](https://github.com/open-webui/open-webui/pull/21984), [Commit](https://github.com/open-webui/open-webui/commit/668bd44485bdf88e9083c6f09c3c47ab97a128a4)
- 🧠 **Reasoning model KV cache preservation.** Reasoning model thinking tags are no longer stored as HTML in the database, preserving KV cache efficiency for backends like llama.cpp and ensuring faster subsequent conversation turns. [#21815](https://github.com/open-webui/open-webui/issues/21815), [Commit](https://github.com/open-webui/open-webui/commit/81781e6495dcc788c863bbf6b4aa4cf0ddd9fdcc)
- ⚡ **Duplicate model execution prevention.** Models are no longer called twice when no tools are configured, eliminating unnecessary API requests and reducing latency. [#21802](https://github.com/open-webui/open-webui/issues/21802), [Commit](https://github.com/open-webui/open-webui/commit/3c8d658160809f6d651837cf93d89dddc1d17caf)
- 🔐 **OAuth session database error.** OAuth login no longer fails with a database error when creating sessions, fixing the "'NoneType' object has no attribute 'id'" and "can't adapt type 'dict'" errors that occurred during OAuth group creation. [#21788](https://github.com/open-webui/open-webui/issues/21788)
- 👤 **User sharing permission enforcement.** The user sharing option now correctly respects the USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS setting, fixing an issue where sharing to individual users was incorrectly allowed even when disabled. [#21856](https://github.com/open-webui/open-webui/pull/21856), [Commit](https://github.com/open-webui/open-webui/commit/acb21470241ed6fd3eb3f659f196f697c418d9e8), [Commit](https://github.com/open-webui/open-webui/commit/ace69bba7512dc0a653695f9e6311712dfabb640)
- 🔑 **Password manager autofill.** Password manager autofill (like iCloud Passwords, 1Password, Bitwarden) now correctly captures filled-in passwords, fixing login failures where the password appeared filled but was sent as empty. [#21869](https://github.com/open-webui/open-webui/pull/21869), [Commit](https://github.com/open-webui/open-webui/commit/9dff497abf821dfba6eb8ea65e48a657ee91fd71)
- 📝 **RAG template duplication.** RAG templates are no longer duplicated in chat messages when models make multiple tool calls, preventing hallucinations and incorrect tool usage. [#21780](https://github.com/open-webui/open-webui/issues/21780), [Commit](https://github.com/open-webui/open-webui/commit/8f49725aa5f2d9b87e559e7d3f02f037335b7914)
- 📋 **Audit log stdout.** Audit logs now correctly appear on stdout when the ENABLE_AUDIT_STDOUT environment variable is set to true, aligning runtime behavior with the intended configuration. [#21777](https://github.com/open-webui/open-webui/pull/21777)
- 🎯 **Function valve priority resolution.** Function priorities defined in code are now correctly applied when no custom value has been saved in the database, ensuring consistent action button and filter ordering. [#21841](https://github.com/open-webui/open-webui/pull/21841)
- 📄 **Web content knowledge base append.** Processing web URLs with overwrite=false now correctly appends content to existing knowledge bases instead of silently doing nothing, fixing a regression where no content was being added. [#21786](https://github.com/open-webui/open-webui/pull/21786), [Commit](https://github.com/open-webui/open-webui/commit/5ee509325970f01524348b0f91081110340f2e7e)
- 🔍 **Web search domain filter config.** The WEB_SEARCH_DOMAIN_FILTER_LIST environment variable is now correctly read and applied, fixing an issue where domain filtering for web searches always used an empty default value. [#21964](https://github.com/open-webui/open-webui/pull/21964), [#20186](https://github.com/open-webui/open-webui/issues/20186)
- 🧹 **Tooltip memory leak.** Tooltip instances are now properly destroyed when elements change, fixing a memory leak that could cause performance issues over time. [#21969](https://github.com/open-webui/open-webui/pull/21969)
- ⌨️ **MessageInput memory leak.** Event listeners in the message input component are now properly cleaned up, preventing a memory leak that could cause page crashes during extended use. [#21968](https://github.com/open-webui/open-webui/pull/21968)
- 📝 **Notes memory leak.** Event listeners in the Notes component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21963](https://github.com/open-webui/open-webui/pull/21963)
- 🏗️ **Model create memory leak.** Event listeners in the model creation page are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21966](https://github.com/open-webui/open-webui/pull/21966)
- 💬 **MentionList memory leak.** Event listeners in the MentionList component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21965](https://github.com/open-webui/open-webui/pull/21965)
- 📐 **Sidebar memory leak.** Event listeners in the Sidebar component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#22082](https://github.com/open-webui/open-webui/pull/22082)
- 🎨 **Sidebar user menu positioning.** The sidebar user menu no longer drifts rightward when the sidebar is resized, keeping the menu properly aligned with its trigger. [#21853](https://github.com/open-webui/open-webui/pull/21853)
- 💻 **Code block UI.** Code block headers are now sticky and properly positioned, with language labels now showing tooltips for truncated text. [Commit](https://github.com/open-webui/open-webui/commit/6b462ff121d28cd2d335db7763052622d374e3a5)
- 📊 **Multi-model responses horizontal scroll.** The model list in multi-model responses tabs now has horizontal scroll support, making all models accessible on desktop screens. [#21800](https://github.com/open-webui/open-webui/issues/21800), [Commit](https://github.com/open-webui/open-webui/commit/a3de0bcc586ddd14dde6ae915067f082d628eaeb)
- 🎭 **TailwindCSS gray color theme.** Custom gray color palette is now correctly applied to the CSS root theme layer, fixing an issue where --color-gray-x variables were missing. [#21900](https://github.com/open-webui/open-webui/pull/21900), [#21899](https://github.com/open-webui/open-webui/issues/21899)
- 📎 **Broken documentation links.** Fixed broken links in the backend config and admin settings that pointed to outdated documentation locations. [#21904](https://github.com/open-webui/open-webui/pull/21904)
- 🔓 **OAuth session token decryption.** OAuth sessions are now properly detached from the database context before token decryption, preventing potential database session conflicts when reading encrypted tokens. [#21794](https://github.com/open-webui/open-webui/pull/21794)
- 🕐 **Chat timestamp i18n fix.** Chat timestamps in the sidebar now display correctly, fixing an issue where the time ago format (e.g., "5m", "2h", "3d") was not being localized properly due to incorrect variable casing in the translation function. [Commit](https://github.com/open-webui/open-webui/commit/ae28e7d24530eb9f7909b293bcd0f33048a022a9)
- 🍞 **Model toast notification fix.** Hiding or showing a single model now displays only one toast notification instead of two, removing the redundant generic "model updated" message when a specific action toast is shown. [#22079](https://github.com/open-webui/open-webui/pull/22079)
- 📡 **Offline mode embedding model fix.** Open WebUI no longer attempts to download embedding models when in offline mode, fixing error logs that occurred when trying to fetch models that weren't cached locally. [#22106](https://github.com/open-webui/open-webui/pull/22106), [#21405](https://github.com/open-webui/open-webui/issues/21405)
## [0.8.5] - 2026-02-23
### Added
- ⌨️ **Voice dictation shortcut.** Users can now toggle voice dictation using Cmd+Shift+L (or Ctrl+Shift+L on Windows/Linux), making it faster to start and stop dictation without clicking the microphone button.
### Fixed
- 🚫 **Model access KeyError fix.** The /api/models endpoint no longer crashes with a 500 error when models have incomplete info metadata missing the user_id field (e.g. models using global default metadata).
- 🔄 **Frontend initialization resilience.** The app layout now gracefully handles individual API failures during initialization (getModels, getBanners, getTools, getUserSettings, setToolServers) instead of blocking the entire page load when any single call fails.
- 🛡️ **Backend config null safety.** Language detection during app initialization no longer crashes when the backend config fetch fails, preventing a secondary cause of infinite loading.
## [0.8.4] - 2026-02-23
### Added
- 🛜 **Provider URL suggestions.** The connection form now displays a dropdown with suggested URLs for popular AI providers, making it easier to configure connections. [Commit](https://github.com/open-webui/open-webui/commit/49c36238d01aaff5466344ecd316a6dd3edd74a3)
- ☁️ **Anthropic model fetching.** The system now properly fetches available models from the Anthropic API, ensuring all Anthropic models are accessible. [Commit](https://github.com/open-webui/open-webui/commit/e9d852545cc17f0eeb8bdcfa77575a80fed8706d)
- 💡 **No models prompt.** When no models are available, a helpful prompt now guides users to manage their provider connections. [Commit](https://github.com/open-webui/open-webui/commit/a0195cd5ae9b9915295839cd0a5fbac5a1b0bfa2)
- ⚙️ **Connection enable/disable toggles.** Individual provider connections can now be enabled or disabled from both admin and user settings. [Commit](https://github.com/open-webui/open-webui/commit/990c638f6cf91507b61898f454c26f9516114c36)
- ⏸️ **Prompt enable/disable toggle.** Users can now enable or disable prompts directly from the prompts list using a toggle switch, without needing to delete and recreate them. Inactive prompts display an "Inactive" badge and are still visible in the list. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 🗑️ **Memory deletion.** Agents can now delete specific memories that are no longer relevant, duplicated, or incorrect, giving better control over stored memory content. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 📋 **Memory listing.** Agents can now list all stored memories, enabling them to identify which memories to manage or delete based on the complete memory inventory. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 📦 **Auto pip install toggle.** Administrators can now disable automatic pip package installation from function frontmatter requirements using the ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS environment variable, providing more control over function dependency management. [Commit](https://github.com/open-webui/open-webui/commit/8bfab327ec5f635f9fe93c26efd198712ff7116d)
- 🔗 **Anthropic Messages API proxy.** A new API endpoint now supports the Anthropic Messages API format, allowing tools like Claude Code to authenticate through Open WebUI and access configured models. Tool calls are now properly supported in streaming responses with correct multi-block indexing, and error status from tools is propagated correctly. The endpoint converts requests to OpenAI format internally, routes them through the existing chat pipeline, and returns responses in Anthropic format. [#21390](https://github.com/open-webui/open-webui/discussions/21390), [Commit](https://github.com/open-webui/open-webui/commit/91a0301c9e22e93295a7c471d83592a802560795), [Commit](https://github.com/open-webui/open-webui/commit/a9312d25373d3aa161788598f87180b8db11c5b6)
- 👥 **Multi-device OAuth sessions.** Users can now stay logged in on multiple devices simultaneously with OAuth, as re-logging in no longer terminates existing sessions. The oldest sessions are automatically pruned when the session limit is exceeded. [#21647](https://github.com/open-webui/open-webui/issues/21647), [Commit](https://github.com/open-webui/open-webui/commit/ae05586fdabf318d551b53ede41575355d3b9e2b)
- 🔐 **OAuth group default share setting.** Administrators can now configure the default sharing setting for OAuth-created groups using the OAUTH_GROUP_DEFAULT_SHARE environment variable, allowing control over whether new groups default to private or shared with members. [#21679](https://github.com/open-webui/open-webui/pull/21679), [Commit](https://github.com/open-webui/open-webui/commit/4b9f821b58007d4efa4aa16a4995b23126e08a88)
- 🔧 **Knowledge base import behavior.** The web content import endpoint now supports a configurable overwrite flag, allowing users to add multiple URLs to the same knowledge base instead of replacing existing content. [#21613](https://github.com/open-webui/open-webui/pull/21613), [#21336](https://github.com/open-webui/open-webui/issues/21336), [Commit](https://github.com/open-webui/open-webui/commit/4bef69cc6344ff809090441aa6bced573a2aa838)
- 🧩 **Skill JSON import support.** Skills can now be imported from both JSON and Markdown files. [#21511](https://github.com/open-webui/open-webui/issues/21511)
- 🔍 **You.com web search provider.** A new web search provider option for You.com is now available, giving users another search engine choice for web-enabled models. The You.com provider enriches search results by including both descriptions and snippets for better context. [#21599](https://github.com/open-webui/open-webui/pull/21599)
- 🚀 **Message list performance.** Loading conversation history when sending messages is now significantly faster, improving response latency before the model starts generating. This also speeds up chat search and RAG context building. [#21588](https://github.com/open-webui/open-webui/pull/21588)
- 🎯 **Concurrent embedding request control.** Administrators can now control the maximum number of concurrent embedding API requests using the RAG_EMBEDDING_CONCURRENT_REQUESTS environment variable, helping manage API rate limits while maintaining embedding performance. [#21662](https://github.com/open-webui/open-webui/pull/21662), [Commit](https://github.com/open-webui/open-webui/commit/5d4547f934b6fbe751bb2041f9597fe11ddf8e43)
- ⚡ **Message upsert optimization.** Loading chat data during message saving is now significantly faster by eliminating a redundant database call that occurred on every message upsert, which happens many times during streaming responses. [#21592](https://github.com/open-webui/open-webui/pull/21592)
- ⚡ **Message send optimization.** Loading chat data during message sending is now significantly faster by eliminating unnecessary full conversation history loads. The system now uses targeted queries that fetch only the needed data instead of loading entire chat objects with all message history. [#21596](https://github.com/open-webui/open-webui/pull/21596)
- 🚀 **Tag filtering optimization.** Chat search with tag filtering now uses more efficient database queries, making filtered searches significantly faster. [Commit](https://github.com/open-webui/open-webui/commit/139f02a9d9fa2ffffcc96aa0de8af8ef51b6bcf2)
- ⚡ **Shared chat loading optimization.** The shared chats endpoint now loads only the needed columns instead of the full conversation history, making shared chat listings significantly faster. [#21614](https://github.com/open-webui/open-webui/pull/21614)
- 🗂️ **Archived and pinned chat loading.** Loading archived and pinned chat lists is now significantly faster by loading only the needed columns instead of full conversation data. [#21591](https://github.com/open-webui/open-webui/pull/21591)
- 💨 **Chat title query optimization.** Retrieving chat titles now queries only the title column instead of the entire conversation history, making title lookups significantly faster and reducing database load. [#21590](https://github.com/open-webui/open-webui/pull/21590)
- 🗄️ **Batch access grants for multiple resources.** Loading channels, knowledge bases, models, notes, prompts, skills, and tools now uses batch database queries for access grants instead of individual queries per item, significantly reducing database load. For 30 items, this reduces approximately 31 queries to just 3. [#21616](https://github.com/open-webui/open-webui/pull/21616)
- 📋 **Notes list payload optimization.** Notes list and search endpoints now return only a 200-character preview instead of the full note content, reducing response payload from ~167 MB to ~10 KB for 60 notes and eliminating N+1 queries for access grants. The Notes tab now loads in seconds instead of tens of seconds. [#21549](https://github.com/open-webui/open-webui/pull/21549)
- ⚡ **Tools list performance.** Loading the tools list is now significantly faster by deferring content and specs fields from database queries, and using cached tool modules instead of reloading them for each request. [Commit](https://github.com/open-webui/open-webui/commit/b48594a16680cc77921a4ed1a11ffa07df7edc60)
- 📝 **Group description display.** The admin groups list now shows each group's description, making it easier for administrators to identify groups at a glance.
- 🏷️ **Sort by dropdown.** Administrators can now sort groups using a dropdown menu with options for Name or Members, replacing the previous clickable column headers.
- 📶 **Admin groups list sorting.** The Group and Users columns in the admin groups list are now clickable for sorting, allowing administrators to sort groups alphabetically by name or numerically by member count. [#21692](https://github.com/open-webui/open-webui/pull/21692)
- 🔽 **Rich UI auto-scroll.** The view now automatically scrolls to action-generated Rich UI content once it renders, ensuring users can see the results without manually scrolling. [#21698](https://github.com/open-webui/open-webui/pull/21698), [#21482](https://github.com/open-webui/open-webui/discussions/21482)
- 📊 **Admin analytics toggle.** Administrators can now enable or disable the analytics feature using the ENABLE_ADMIN_ANALYTICS environment variable, giving more control over available admin features. [#21651](https://github.com/open-webui/open-webui/pull/21651), [Commit](https://github.com/open-webui/open-webui/commit/35598b8017557258b8c9ee3469d320adb0140751)
- 📊 **Analytics sorting enhancement.** The Analytics dashboard now supports sorting by Tokens column for both Model Usage and User Usage tables, and the Share/Percentage columns are now clickable for sorting. Administrators can more easily identify the most token-consuming models and users. [Commit](https://github.com/open-webui/open-webui/commit/053a33631f575ae1ad3123190a9e820b4057f62d)
- 📑 **Fetch URL citation sources.** When models fetch URLs during tool calling, the fetched URLs now appear as clickable citation sources in the UI with content previews, matching the existing behavior of web search and knowledge file tools. [#21669](https://github.com/open-webui/open-webui/pull/21669)
- 🔗 **Admin settings tab navigation.** The admin settings sidebar now supports native browser tab opening, allowing users to middle-click or right-click to open settings pages in new tabs. The navigation was converted from button-based to anchor-based elements. [#21721](https://github.com/open-webui/open-webui/pull/21721)
- 🏷️ **Model visibility badges.** The Admin Settings Models page now displays Public or Private badges directly on each model, making it easy to identify model access levels at a glance without opening the edit screen. [#21732](https://github.com/open-webui/open-webui/issues/21732), [Commit](https://github.com/open-webui/open-webui/commit/29217cb430bd47827ebb20782b264ae7b0f233bb)
- 🛠️ **Global model defaults.** Administrators can now configure default metadata and parameters that automatically apply to all models, reducing manual configuration for newly discovered models. Default capabilities (like vision, web search, code interpreter) and parameters (like temperature, max_tokens) can be set globally in Admin Settings, with per-model overrides still available. [#20658](https://github.com/open-webui/open-webui/issues/20658), [Commit](https://github.com/open-webui/open-webui/commit/c341f97cfe15510b7d128bd84f1e607b5289b957)
- 💬 **Plaintext tool output display.** Tool outputs that are plain strings now display naturally in a monospace block instead of quoted/escaped format, making multi-line string outputs easier to read. [#21553](https://github.com/open-webui/open-webui/issues/21553), [Commit](https://github.com/open-webui/open-webui/commit/3ad2ea6f2839e97e53f00fd797a9e083ff78d88e)
- 🔐 **Event call input masking.** Functions can now request masked password input in confirmation dialogs, allowing sensitive data entry to be hidden from view. This extends the existing masking feature from user valves to event calls. [#21540](https://github.com/open-webui/open-webui/issues/21540), [Commit](https://github.com/open-webui/open-webui/commit/4853ededcabcd76d9bd2036181486cd3a41458a1)
- 🗂️ **JSON logging support.** Administrators can now enable JSON-formatted logging by setting the LOG_FORMAT environment variable to "json", making logs suitable for log aggregators like Loki, Fluentd, CloudWatch, and Datadog. [#21747](https://github.com/open-webui/open-webui/pull/21747)
- ♿ **UI accessibility improvements.** Screen reader users can now navigate the interface more easily with improved keyboard navigation in dialogs and proper ARIA labels on all interactive elements. Added aria-labels to close, back, and action buttons across various components, and improved semantic HTML and screen reader support across auth, sidebar, chat, and notification components, addressing WCAG compliance. Added aria-labels to search inputs, select fields, and modals in admin and user settings, and improved accessibility for text inputs, rating components, citations, and web search results. Added aria-labels to workspace components including Knowledge, Models, Prompts, Skills, and Tools pages for improved screen reader support. [#21706](https://github.com/open-webui/open-webui/pull/21706), [#21705](https://github.com/open-webui/open-webui/pull/21705), [#21710](https://github.com/open-webui/open-webui/pull/21710), [#21709](https://github.com/open-webui/open-webui/pull/21709), [#21717](https://github.com/open-webui/open-webui/pull/21717), [#21715](https://github.com/open-webui/open-webui/pull/21715), [#21708](https://github.com/open-webui/open-webui/pull/21708), [#21719](https://github.com/open-webui/open-webui/pull/21719)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Finnish, French, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
### Fixed
- 💥 **Admin functions page crash fix.** The admin Functions tab no longer crashes when clicked, fixing a null reference error that occurred while the functions list was loading. [#21661](https://github.com/open-webui/open-webui/pull/21661), [Commit](https://github.com/open-webui/open-webui/commit/8265422ba0660e7ba2192eb19efd70f8be652748)
- 💀 **Cyclic chat history deadlock fix.** Chat histories with circular parent-child message references no longer cause the backend to freeze when syncing usage stats. The system now detects and safely aborts when encountering cyclic message references. [#21681](https://github.com/open-webui/open-webui/pull/21681)
- 🔀 **Model fallback routing fix.** Custom model fallback now works correctly across all model types, preventing "Model not found" errors when the fallback model uses a different backend (pipe, Ollama, or OpenAI). [#21736](https://github.com/open-webui/open-webui/pull/21736)
- 🐛 **Default model selection fix.** Admin-configured default models are now properly respected when starting new chats instead of being overwritten by the first available model. [#21736](https://github.com/open-webui/open-webui/pull/21736)
- 👁️ **Scroll jumping fix.** Deleting a message pair after stopping generation no longer causes the chat to visually jump around, making message deletion smoother. [#21743](https://github.com/open-webui/open-webui/pull/21743), [Commit](https://github.com/open-webui/open-webui/commit/1f474187a77d2c8a392f00d86f48eb3cb3a18b88)
- 💬 **New chat message handling fix.** Fixed a bug where clicking "New Chat" after sending a message would silently drop subsequent messages. The system now properly clears pending message queues when starting a new conversation. [#21731](https://github.com/open-webui/open-webui/pull/21731)
- 🔍 **RAG template mutation fix.** Fixed a bug where RAG template text was recursively injected into user messages during multiple sequential tool calls, causing message content to grow exponentially and potentially confuse the model. The system now preserves the original user message before tool-calling loops and correctly accumulates citation sources. [#21663](https://github.com/open-webui/open-webui/issues/21663), [#21668](https://github.com/open-webui/open-webui/pull/21668), [Commit](https://github.com/open-webui/open-webui/commit/becac2b2b7af8aacadbfc9b7cee2024cf7ed6acc)
- 🔒 **Iframe sandbox security.** Embedded tools can no longer submit forms or access same-origin content by default, improving security for users. [#21529](https://github.com/open-webui/open-webui/pull/21529)
- 🔐 **Signup race condition fix.** Fixed a security vulnerability where multiple admin accounts could be created on fresh deployments when running multiple uvicorn workers. The signup handler now properly handles concurrent requests during first-user registration, preventing unauthorized admin privilege escalation. [#21631](https://github.com/open-webui/open-webui/pull/21631)
- 🔐 **LDAP optional fields fix.** LDAP configuration now properly accepts empty Application DN and password values, allowing LDAP authentication to work without these optional fields. Previously, empty values caused authentication failures. [Commit](https://github.com/open-webui/open-webui/commit/e1fa42d48a15c8b496a887ecfa32fc01cfd74b36)
- 🛠️ **API tools fix.** The /api/v1/chat/completions endpoint now properly respects caller-provided tools instead of overriding them with server-side tools, fixing issues where external agents like Claude Code or Cursor would receive unexpected tool advertisements. [#21557](https://github.com/open-webui/open-webui/issues/21557), [#21555](https://github.com/open-webui/open-webui/pull/21555)
- ⏱️ **Embeddings and proxy timeout fix.** The embeddings and OpenAI proxy endpoints now properly honor the AIOHTTP_CLIENT_TIMEOUT environment variable, instead of using default timeouts that could cause requests to hang. [#21558](https://github.com/open-webui/open-webui/pull/21558)
- 📄 **Text file type detection fix.** TypeScript and other text files that were mis-detected as video files based on their extension are now correctly identified and processed as text files, fixing upload rejections for .ts files. [#21454](https://github.com/open-webui/open-webui/issues/21454), [Commit](https://github.com/open-webui/open-webui/commit/f651809001ba8e40ba5f416773c1aa6f082a6c46)
- 🗄️ **File access control respect.** The files list and search endpoints now properly respect the BYPASS_ADMIN_ACCESS_CONTROL setting, ensuring admins only see their own files when the setting is disabled, consistent with other endpoints. [#21595](https://github.com/open-webui/open-webui/pull/21595), [#21589](https://github.com/open-webui/open-webui/issues/21589)
- 🗄️ **PostgreSQL workspace cloning.** Cloning workspace models now works correctly on PostgreSQL databases by generating proper unique IDs for access grants instead of using potentially duplicate or invalid IDs. [Commit](https://github.com/open-webui/open-webui/commit/3dd44c4f1931d13bfd46062291c6f23b33dde003)
- 🔓 **MCP SSL verification fix.** MCP tool connections now properly respect the AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL environment variable to disable SSL verification, instead of always verifying SSL certificates. [Commit](https://github.com/open-webui/open-webui/commit/af5661c2c807465f5600899e8c1a421f96cd7a8c), [#21481](https://github.com/open-webui/open-webui/issues/21481)
- 🔒 **Model default feature permissions.** Model default features like code interpreter, web search, and image generation now respect global configuration and user permission settings, preventing disabled features from appearing in the chat input. [#21690](https://github.com/open-webui/open-webui/pull/21690)
- 🔍 **Model selector typing fix.** The model selector list no longer disappears or becomes grayed out when typing quickly in the search field, thanks to improved virtual scroll handling. [#21659](https://github.com/open-webui/open-webui/pull/21659)
- ⛔ **Disabled model cloning prevention.** Disabled models can no longer be cloned as workspace models, preventing invalid empty configurations from being created. The Clone option is now hidden for inactive models. [#21724](https://github.com/open-webui/open-webui/pull/21724)
- 🔧 **SCIM parameter handling.** The SCIM Users and Groups endpoints now accept out-of-range startIndex and count values by clamping them to valid ranges instead of returning errors, in compliance with RFC 7644. [#21577](https://github.com/open-webui/open-webui/pull/21577)
- 🔍 **Hybrid search result fix.** Hybrid search now returns correct results after fixing a bug where query result unpacking order was mismatched, causing search results to appear empty. [#21562](https://github.com/open-webui/open-webui/pull/21562)
- 🛠️ **Imported items display.** Imported functions and tools now appear immediately in the list after import, without requiring a page reload. [#21593](https://github.com/open-webui/open-webui/issues/21593)
- 🔄 **WebSocket race condition fix.** Collaborative note saves no longer crash with errors when users disconnect before pending saves complete, preventing AttributeError exceptions and excessive logging. [#21601](https://github.com/open-webui/open-webui/issues/21601), [Commit](https://github.com/open-webui/open-webui/commit/0a700aafe46dfea2cf9721bb81725d2582b0d781)
- ✋ **Drag-and-drop overlay fix.** The "Add Files" overlay no longer remains stuck on screen when dragging files back out of the chat window in Mozilla Firefox. [#21664](https://github.com/open-webui/open-webui/pull/21664)
- 👁️ **Group search visibility fix.** Groups now appear correctly in access control search results, even when the search doesn't match any users. [#21691](https://github.com/open-webui/open-webui/pull/21691)
- 🖱️ **User menu drag and click fixes.** Fixed draggable ghost images when dragging menu items and eliminated phantom link clicks that occurred when dragging outside dropdown menus. [#21699](https://github.com/open-webui/open-webui/pull/21699)
- 🧭 **Admin and workspace nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Admin and Workspace panels by adding proper drag constraints and text selection prevention. [#21701](https://github.com/open-webui/open-webui/pull/21701)
- 🎮 **Playground nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Playground panel by adding proper drag constraints and text selection prevention. [#21704](https://github.com/open-webui/open-webui/pull/21704)
- ✋ **Dropdown menu drag fix.** Dropdown menu items can no longer be accidentally dragged as ghost images when highlighting text, making menu interactions smoother. [#21713](https://github.com/open-webui/open-webui/pull/21713)
- 🗂️ **Folder menu drag fix.** Folder dropdown menu items can no longer be accidentally highlighted or dragged as ghost images, making folder options behave like standard menus. [#21753](https://github.com/open-webui/open-webui/pull/21753)
- 📝 **Console log spam fix.** Requesting deleted or missing files no longer floods the backend console with Python traceback logs, thanks to proper exception handling for expected 404 errors. [#21687](https://github.com/open-webui/open-webui/pull/21687)
- 🐛 **Firefox avatar overflow fix.** Fixed a visual bug in Firefox where broken model or user avatar images would display overflowing alt text that overlapped adjacent labels on the Analytics and Leaderboard pages. Failed avatar images now properly show fallback icons instead. [#21730](https://github.com/open-webui/open-webui/pull/21730)
- 🎨 **Dark mode select background fix.** Fixed an issue where select inputs and dropdown menus had inconsistent lighter background colors in dark mode by removing conflicting dark theme overrides, ensuring a cohesive transparent look. [#21728](https://github.com/open-webui/open-webui/pull/21728)
- 💾 **Prompt import fix.** Importing prompts that were previously exported no longer fails with a "[object Object]" error toast, making prompt backup and restore work correctly. [#21594](https://github.com/open-webui/open-webui/issues/21594)
- 🔧 **Ollama reasoning effort fix.** Reasoning effort now works correctly with Ollama models that require string values ("low", "medium", "high") instead of boolean, fixing "invalid option provided" errors when using models like GPT-OSS. [#20921](https://github.com/open-webui/open-webui/issues/20921), [#20928](https://github.com/open-webui/open-webui/pull/20928), [Commit](https://github.com/open-webui/open-webui/commit/30a13b9b2fb2c6da7e1ddbf52edb93a58d09cc56)
- 🔍 **Hybrid search deduplication fix.** Hybrid search now correctly deduplicates results using content hashes, preventing duplicate chunks from appearing when using enriched text for BM25 search. [Commit](https://github.com/open-webui/open-webui/commit/d9fd2a3f30481efa24cc54193bf2f67fd0299b52)
- 📋 **SQLAlchemy warning fix.** Fixed a SQLAlchemy warning that appeared in logs when deleting shared chats, improving log clarity. [Commit](https://github.com/open-webui/open-webui/commit/0185f3340d2778f3b75a8036b0e81a0aec78037f)
### Changed
- 🎯 **Prompt suggestions relocated.** Prompt suggestions have been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - Models, where they can now be configured per-model or globally via the new model defaults.
- 📢 **Banners relocated.** Banners configuration has been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - General.
## [0.8.3] - 2026-02-17
### Added
+3 -3
View File
@@ -1,7 +1,7 @@
# Open WebUI Contributor License Agreement
# Contributor License Agreement
By submitting my contributions to Open WebUI, I grant Open WebUI full freedom to use my work in any way they choose, under any terms they like, both now and in the future. This approach helps ensure the project remains unified, flexible, and easy to maintain, while empowering Open WebUI to respond quickly to the needs of its users and the wider community.
By submitting my contributions to this repository in any form, I grant Open WebUI Inc. a perpetual, worldwide, irrevocable, royalty-free license, under copyright and patent, to use, modify, distribute, sublicense, and commercialize my work under any terms they choose, both now and in the future.
Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the Open WebUI project. This collaborative approach strengthens the project’s future and helps guarantee that improvements can always be shared and distributed in the most effective way possible.
I represent that my contributions are my original work (or that I have sufficient rights to grant this license) and that I have the authority to enter into this agreement.
**_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._**
+2 -2
View File
@@ -12,7 +12,7 @@
![Open WebUI Banner](./banner.png)
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
**Open WebUI is an [extensible](https://docs.openwebui.com/features/extensibility/plugin), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
@@ -172,7 +172,7 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/advanced-topics/development) for instructions on setting up a local development environment.
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment.
### Troubleshooting
+130 -20
View File
@@ -322,7 +322,7 @@ JWT_EXPIRES_IN = PersistentConfig(
if JWT_EXPIRES_IN.value == "-1":
log.warning(
"⚠️ SECURITY WARNING: JWT_EXPIRES_IN is set to '-1'\n"
" See: https://docs.openwebui.com/getting-started/env-configuration\n"
" See: https://docs.openwebui.com/reference/env-configuration\n"
)
####################################
@@ -339,6 +339,12 @@ ENABLE_OAUTH_SIGNUP = PersistentConfig(
os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true",
)
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = PersistentConfig(
"OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE",
"oauth.refresh_token_include_scope",
os.environ.get("OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", "False").lower() == "true",
)
OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig(
"OAUTH_MERGE_ACCOUNTS_BY_EMAIL",
@@ -573,6 +579,20 @@ ENABLE_OAUTH_GROUP_CREATION = PersistentConfig(
)
oauth_group_default_share = (
os.environ.get("OAUTH_GROUP_DEFAULT_SHARE", "true").strip().lower()
)
OAUTH_GROUP_DEFAULT_SHARE = PersistentConfig(
"OAUTH_GROUP_DEFAULT_SHARE",
"oauth.group_default_share",
(
"members"
if oauth_group_default_share == "members"
else oauth_group_default_share == "true"
),
)
OAUTH_BLOCKED_GROUPS = PersistentConfig(
"OAUTH_BLOCKED_GROUPS",
"oauth.blocked_groups",
@@ -628,6 +648,18 @@ OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig(
os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true",
)
OAUTH_UPDATE_NAME_ON_LOGIN = PersistentConfig(
"OAUTH_UPDATE_NAME_ON_LOGIN",
"oauth.update_name_on_login",
os.environ.get("OAUTH_UPDATE_NAME_ON_LOGIN", "False").lower() == "true",
)
OAUTH_UPDATE_EMAIL_ON_LOGIN = PersistentConfig(
"OAUTH_UPDATE_EMAIL_ON_LOGIN",
"oauth.update_email_on_login",
os.environ.get("OAUTH_UPDATE_EMAIL_ON_LOGIN", "False").lower() == "true",
)
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = (
os.environ.get("OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID", "False").lower()
== "true"
@@ -1157,6 +1189,20 @@ TOOL_SERVER_CONNECTIONS = PersistentConfig(
tool_server_connections,
)
####################################
# TERMINAL_SERVER
####################################
terminal_server_connections = json.loads(
os.environ.get("TERMINAL_SERVER_CONNECTIONS", "[]")
)
TERMINAL_SERVER_CONNECTIONS = PersistentConfig(
"TERMINAL_SERVER_CONNECTIONS",
"terminal_server.connections",
terminal_server_connections,
)
####################################
# WEBUI
####################################
@@ -1249,6 +1295,18 @@ MODEL_ORDER_LIST = PersistentConfig(
[],
)
DEFAULT_MODEL_METADATA = PersistentConfig(
"DEFAULT_MODEL_METADATA",
"models.default_metadata",
{},
)
DEFAULT_MODEL_PARAMS = PersistentConfig(
"DEFAULT_MODEL_PARAMS",
"models.default_params",
{},
)
DEFAULT_USER_ROLE = PersistentConfig(
"DEFAULT_USER_ROLE",
"ui.default_user_role",
@@ -1407,6 +1465,11 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
os.environ.get("USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS", "True").lower()
== "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
@@ -1428,6 +1491,10 @@ USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_WEB_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_WEB_UPLOAD", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_DELETE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
)
@@ -1560,12 +1627,16 @@ DEFAULT_USER_PERMISSIONS = {
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
"access_grants": {
"allow_users": USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"valves": USER_PERMISSIONS_CHAT_VALVES,
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
"params": USER_PERMISSIONS_CHAT_PARAMS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"web_upload": USER_PERMISSIONS_CHAT_WEB_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
"continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,
@@ -1678,6 +1749,10 @@ ENABLE_ADMIN_CHAT_ACCESS = (
os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true"
)
ENABLE_ADMIN_ANALYTICS = (
os.environ.get("ENABLE_ADMIN_ANALYTICS", "True").lower() == "true"
)
ENABLE_COMMUNITY_SHARING = PersistentConfig(
"ENABLE_COMMUNITY_SHARING",
"ui.enable_community_sharing",
@@ -1891,7 +1966,7 @@ Suggest 3-5 relevant follow-up questions or prompts that the user might naturall
- Only suggest follow-ups that make sense given the chat content and do not repeat what was already covered.
- If the conversation is very short or not specific, suggest more general (but relevant) follow-ups the user might ask.
- Use the conversation's primary language; default to English if multilingual.
- Response must be a JSON array of strings, no extra text or formatting.
- Response must be a JSON object with a "follow_ups" key containing an array of strings, no extra text or formatting.
### Output:
JSON format: { "follow_ups": ["Question 1?", "Question 2?", "Question 3?"] }
### Chat History:
@@ -2221,20 +2296,36 @@ CODE_INTERPRETER_BLOCKED_MODULES = [
]
DEFAULT_CODE_INTERPRETER_PROMPT = """
#### Tools Available
#### Code Interpreter
1. **Code Interpreter**: `<code_interpreter type="code" lang="python"></code_interpreter>`
- You have access to a Python shell that runs directly in the user's browser, enabling fast execution of code for analysis, calculations, or problem-solving. Use it in this response.
- The Python code you write can incorporate a wide array of libraries, handle data manipulation or visualization, perform API calls for web-related tasks, or tackle virtually any computational challenge. Use this flexibility to **think outside the box, craft elegant solutions, and harness Python's full potential**.
- To use it, **you must enclose your code within `<code_interpreter type="code" lang="python">` XML tags** and stop right away. If you don't, the code won't execute.
- When writing code in the code_interpreter XML tag, Do NOT use the triple backticks code block for markdown formatting, example: ```py # python code ``` will cause an error because it is markdown formatting, it is not python code.
- When coding, **always aim to print meaningful outputs** (e.g., results, tables, summaries, or visuals) to better interpret and verify the findings. Avoid relying on implicit outputs; prioritize explicit and clear print statements so the results are effectively communicated to the user.
- After obtaining the printed output, **always provide a concise analysis, interpretation, or next steps to help the user understand the findings or refine the outcome further.**
- If the results are unclear, unexpected, or require validation, refine the code and execute it again as needed. Always aim to deliver meaningful insights from the results, iterating if necessary.
- **If a link to an image, audio, or any file is provided in markdown format in the output, ALWAYS regurgitate word for word, explicitly display it as part of the response to ensure the user can access it easily, do NOT change the link.**
- All responses should be communicated in the chat's primary language, ensuring seamless understanding. If the chat is multilingual, default to English for clarity.
You have access to a Python code interpreter via: `<code_interpreter type="code" lang="python"></code_interpreter>`
Ensure that the tools are effectively utilized to achieve the highest-quality analysis for the user."""
- The Python shell runs directly in the user's browser for fast execution of analysis, calculations, or problem-solving. Use it in this response.
- You can use a wide array of libraries for data manipulation, visualization, API calls, or any computational task. Think outside the box and harness Python's full potential.
- **You must enclose your code within `<code_interpreter type="code" lang="python">` XML tags** and stop right away. If you don't, the code won't execute.
- Do NOT use triple backticks (```py ... ```) inside the XML tags — that is markdown formatting, not executable Python code.
- **Always print meaningful outputs** (results, tables, summaries, visuals). Avoid implicit outputs; use explicit print statements.
- After obtaining output, **provide a concise analysis, interpretation, or next steps** to help the user understand the findings.
- If results are unclear or unexpected, refine the code and re-execute. Iterate until you deliver meaningful insights.
- **If a link to an image, audio, or any file appears in the output, display it exactly as-is** in your response so the user can access it. Do not modify the link.
- Respond in the chat's primary language. Default to English if multilingual.
Ensure the code interpreter is effectively utilized to achieve the highest-quality analysis for the user."""
# Appended to the code interpreter prompt only when engine is pyodide (not jupyter)
CODE_INTERPRETER_PYODIDE_PROMPT = """
##### Pyodide Environment
- This Python environment runs via Pyodide in the browser. **Do not install packages** — `pip install`, `subprocess`, and `micropip.install()` are not available.
- If a required library is unavailable, use an alternative approach with available modules. Do not attempt to install anything.
##### Persistent File System
- User-uploaded files are available at `/mnt/uploads/`. When the user asks you to work with their files, read from this directory.
- You can also write output files to `/mnt/uploads/` so the user can access and download them from the file browser.
- The file system persists across code executions within the same session.
- Use `import os; os.listdir('/mnt/uploads')` to discover available files."""
####################################
@@ -2903,6 +2994,12 @@ ENABLE_ASYNC_EMBEDDING = PersistentConfig(
os.environ.get("ENABLE_ASYNC_EMBEDDING", "True").lower() == "true",
)
RAG_EMBEDDING_CONCURRENT_REQUESTS = PersistentConfig(
"RAG_EMBEDDING_CONCURRENT_REQUESTS",
"rag.embedding_concurrent_requests",
int(os.getenv("RAG_EMBEDDING_CONCURRENT_REQUESTS", "0")),
)
RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None)
RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None)
@@ -3136,17 +3233,24 @@ WEB_SEARCH_RESULT_COUNT = PersistentConfig(
)
try:
web_search_domain_filter_list = json.loads(
os.getenv("WEB_SEARCH_DOMAIN_FILTER_LIST", "[]")
)
except Exception as e:
web_search_domain_filter_list = [
# "wikipedia.com",
# "wikimedia.org",
# "wikidata.org",
# "!stackoverflow.com",
]
# You can provide a list of your own websites to filter after performing a web search.
# This ensures the highest level of safety and reliability of the information sources.
WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
"WEB_SEARCH_DOMAIN_FILTER_LIST",
"rag.web.search.domain.filter_list",
[
# "wikipedia.com",
# "wikimedia.org",
# "wikidata.org",
# "!stackoverflow.com",
],
web_search_domain_filter_list,
)
WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
@@ -3485,6 +3589,12 @@ YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
)
YOUCOM_API_KEY = PersistentConfig(
"YOUCOM_API_KEY",
"rag.web.search.youcom_api_key",
os.environ.get("YOUCOM_API_KEY", ""),
)
####################################
# Images
####################################
+78 -1
View File
@@ -5,6 +5,9 @@ import os
import pkgutil
import sys
import shutil
import traceback
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from pathlib import Path
from cryptography.hazmat.primitives import serialization
@@ -72,9 +75,51 @@ except Exception:
# LOGGING
####################################
_LEVEL_MAP = {
"DEBUG": "debug",
"INFO": "info",
"WARNING": "warn",
"ERROR": "error",
"CRITICAL": "fatal",
}
class JSONFormatter(logging.Formatter):
"""Format log records as single-line JSON objects for structured logging."""
def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
timespec="milliseconds"
),
"level": _LEVEL_MAP.get(record.levelname, record.levelname.lower()),
"msg": record.getMessage(),
"caller": record.name,
}
if record.exc_info and record.exc_info[0] is not None:
log_entry["error"] = "".join(
traceback.format_exception(*record.exc_info)
).rstrip()
elif record.exc_text:
log_entry["error"] = record.exc_text
if record.stack_info:
log_entry["stacktrace"] = record.stack_info
return json.dumps(log_entry, ensure_ascii=False, default=str)
LOG_FORMAT = os.environ.get("LOG_FORMAT", "").lower()
GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper()
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
if LOG_FORMAT == "json":
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(JSONFormatter())
logging.basicConfig(handlers=[_handler], level=GLOBAL_LOG_LEVEL, force=True)
else:
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
else:
GLOBAL_LOG_LEVEL = "INFO"
@@ -557,6 +602,10 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
# Maximum number of concurrent OAuth sessions per user per provider
# This prevents unbounded session growth while allowing multi-device usage
OAUTH_MAX_SESSIONS_PER_USER = int(os.environ.get("OAUTH_MAX_SESSIONS_PER_USER", "10"))
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = (
@@ -739,6 +788,16 @@ try:
except ValueError:
WEBSOCKET_SERVER_PING_INTERVAL = 25
WEBSOCKET_EVENT_CALLER_TIMEOUT = os.environ.get("WEBSOCKET_EVENT_CALLER_TIMEOUT", "")
if WEBSOCKET_EVENT_CALLER_TIMEOUT == "":
WEBSOCKET_EVENT_CALLER_TIMEOUT = None
else:
try:
WEBSOCKET_EVENT_CALLER_TIMEOUT = int(WEBSOCKET_EVENT_CALLER_TIMEOUT)
except ValueError:
WEBSOCKET_EVENT_CALLER_TIMEOUT = 300
REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true"
@@ -978,6 +1037,11 @@ OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get(
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
os.environ.get("ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS", "True").lower()
== "true"
)
PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split()
PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
@@ -987,3 +1051,16 @@ PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
####################################
EXTERNAL_PWA_MANIFEST_URL = os.environ.get("EXTERNAL_PWA_MANIFEST_URL")
####################################
# GROUP DEFAULTS
####################################
# Controls the default "Who can share to this group" setting for new groups.
# Env var values: "true" (anyone), "false" (no one), "members" (only group members).
_default_group_share = (
os.environ.get("DEFAULT_GROUP_SHARE_PERMISSION", "members").strip().lower()
)
DEFAULT_GROUP_SHARE_PERMISSION = (
"members" if _default_group_share == "members" else _default_group_share == "true"
)
+24 -5
View File
@@ -102,11 +102,30 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
conn.execute(f"PRAGMA key = '{database_password}'")
return conn
engine = create_engine(
"sqlite://", # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
)
# The dummy "sqlite://" URL would cause SQLAlchemy to auto-select
# SingletonThreadPool, which non-deterministically closes in-use
# connections when thread count exceeds pool_size, leading to segfaults
# in the native sqlcipher3 C library. Use NullPool by default for safety,
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
engine = create_engine(
"sqlite://",
creator=create_sqlcipher_connection,
pool_size=DATABASE_POOL_SIZE,
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
poolclass=QueuePool,
echo=False,
)
else:
engine = create_engine(
"sqlite://",
creator=create_sqlcipher_connection,
poolclass=NullPool,
echo=False,
)
log.info("Connected to encrypted SQLite database using SQLCipher")
+172 -44
View File
@@ -96,6 +96,7 @@ from open_webui.routers import (
users,
utils,
scim,
terminals,
)
from open_webui.routers.retrieval import (
@@ -132,6 +133,8 @@ from open_webui.config import (
THREAD_POOL_SIZE,
# Tool Server Configs
TOOL_SERVER_CONNECTIONS,
# Terminal Server
TERMINAL_SERVER_CONNECTIONS,
# Code Execution
ENABLE_CODE_EXECUTION,
CODE_EXECUTION_ENGINE,
@@ -240,6 +243,7 @@ from open_webui.config import (
RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_BATCH_SIZE,
ENABLE_ASYNC_EMBEDDING,
RAG_EMBEDDING_CONCURRENT_REQUESTS,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_RELEVANCE_THRESHOLD,
@@ -359,6 +363,7 @@ from open_webui.config import (
YANDEX_WEB_SEARCH_URL,
YANDEX_WEB_SEARCH_API_KEY,
YANDEX_WEB_SEARCH_CONFIG,
YOUCOM_API_KEY,
# WebUI
WEBUI_AUTH,
WEBUI_NAME,
@@ -392,6 +397,8 @@ from open_webui.config import (
DEFAULT_PINNED_MODELS,
DEFAULT_ARENA_MODEL,
MODEL_ORDER_LIST,
DEFAULT_MODEL_METADATA,
DEFAULT_MODEL_PARAMS,
EVALUATION_ARENA_MODELS,
# WebUI (OAuth)
ENABLE_OAUTH_ROLE_MANAGEMENT,
@@ -432,6 +439,7 @@ from open_webui.config import (
RESPONSE_WATERMARK,
# Admin
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_ANALYTICS,
BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_ADMIN_EXPORT,
# Tasks
@@ -498,6 +506,7 @@ from open_webui.env import (
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
ENABLE_EASTER_EGGS,
LOG_FORMAT,
)
@@ -518,7 +527,7 @@ from open_webui.utils.middleware import (
process_chat_payload,
process_chat_response,
)
from open_webui.utils.tools import set_tool_servers
from open_webui.utils.tools import set_tool_servers, set_terminal_servers
from open_webui.utils.auth import (
get_license_data,
@@ -576,7 +585,8 @@ class SPAStaticFiles(StaticFiles):
raise ex
print(rf"""
if LOG_FORMAT != "json":
print(rf"""
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
@@ -683,8 +693,13 @@ async def lifespan(app: FastAPI):
)
await set_tool_servers(mock_request)
log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)")
await set_terminal_servers(mock_request)
log.info(
f"Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)"
)
except Exception as e:
log.warning(f"Failed to initialize tool servers at startup: {e}")
log.warning(f"Failed to initialize tool/terminal servers at startup: {e}")
yield
@@ -768,6 +783,15 @@ app.state.OPENAI_MODELS = {}
app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS
app.state.TOOL_SERVERS = []
########################################
#
# TERMINAL SERVER
#
########################################
app.state.config.TERMINAL_SERVER_CONNECTIONS = TERMINAL_SERVER_CONNECTIONS
app.state.TERMINAL_SERVERS = []
########################################
#
# DIRECT CONNECTIONS
@@ -819,6 +843,8 @@ app.state.config.ADMIN_EMAIL = ADMIN_EMAIL
app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
app.state.config.DEFAULT_PINNED_MODELS = DEFAULT_PINNED_MODELS
app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.DEFAULT_MODEL_METADATA = DEFAULT_MODEL_METADATA
app.state.config.DEFAULT_MODEL_PARAMS = DEFAULT_MODEL_PARAMS
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
@@ -980,6 +1006,7 @@ app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE
app.state.config.ENABLE_ASYNC_EMBEDDING = ENABLE_ASYNC_EMBEDDING
app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = RAG_EMBEDDING_CONCURRENT_REQUESTS
app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE
app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
@@ -1065,6 +1092,7 @@ app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
app.state.config.YOUCOM_API_KEY = YOUCOM_API_KEY
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
@@ -1133,6 +1161,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
else None
),
enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING,
concurrent_requests=app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
)
app.state.RERANKING_FUNCTION = get_reranking_function(
@@ -1369,46 +1398,52 @@ app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
auth_header = request.headers.get("Authorization")
token = None
class APIKeyRestrictionMiddleware:
def __init__(self, app):
self.app = app
if auth_header:
parts = auth_header.split(" ", 1)
if len(parts) == 2:
token = parts[1]
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
request = Request(scope)
auth_header = request.headers.get("Authorization")
token = None
# Only apply restrictions if an sk- API key is used
if token and token.startswith("sk-"):
# Check if restrictions are enabled
if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip()
for path in str(
request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS
).split(",")
if path.strip()
]
if auth_header:
parts = auth_header.split(" ", 1)
if len(parts) == 2:
token = parts[1]
request_path = request.url.path
# Only apply restrictions if an sk- API key is used
if token and token.startswith("sk-"):
# Check if restrictions are enabled
if app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip()
for path in str(
app.state.config.API_KEYS_ALLOWED_ENDPOINTS
).split(",")
if path.strip()
]
# Match exact path or prefix path
is_allowed = any(
request_path == allowed or request_path.startswith(allowed + "/")
for allowed in allowed_paths
)
request_path = request.url.path
if not is_allowed:
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={
"detail": "API key not allowed to access this endpoint."
},
# Match exact path or prefix path
is_allowed = any(
request_path == allowed
or request_path.startswith(allowed + "/")
for allowed in allowed_paths
)
response = await call_next(request)
return response
if not is_allowed:
await JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={
"detail": "API key not allowed to access this endpoint."
},
)(scope, receive, send)
return
await self.app(scope, receive, send)
app.add_middleware(APIKeyRestrictionMiddleware)
@@ -1442,6 +1477,16 @@ async def check_url(request: Request, call_next):
scheme="Bearer", credentials=request.cookies.get("token")
)
# Fallback to x-api-key header for Anthropic Messages API routes
if request.state.token is None and request.headers.get("x-api-key"):
request_path = request.url.path
if request_path in ("/api/message", "/api/v1/messages"):
from fastapi.security import HTTPAuthorizationCredentials
request.state.token = HTTPAuthorizationCredentials(
scheme="Bearer", credentials=request.headers.get("x-api-key")
)
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request)
process_time = int(time.time()) - start_time
@@ -1515,8 +1560,10 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function
app.include_router(
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
)
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
if ENABLE_ADMIN_ANALYTICS:
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
app.include_router(terminals.router, prefix="/api/v1/terminals", tags=["terminals"])
# SCIM 2.0 API for identity management
if ENABLE_SCIM:
@@ -1671,9 +1718,18 @@ async def chat_completion(
request.state.direct = True
request.state.model = model
model_info_params = (
model_info.params.model_dump() if model_info and model_info.params else {}
# Model params: global defaults as base, per-model overrides win
default_model_params = (
getattr(request.app.state.config, "DEFAULT_MODEL_PARAMS", None) or {}
)
model_info_params = {
**default_model_params,
**(
model_info.params.model_dump()
if model_info and model_info.params
else {}
),
}
# Check base model existence for custom models
if model_info_params.get("base_model_id"):
@@ -1688,8 +1744,13 @@ async def chat_completion(
default_models[0].strip() if default_models[0] else None
)
if fallback_model_id:
request.base_model_id = fallback_model_id
if (
fallback_model_id
and fallback_model_id in request.app.state.MODELS
):
# Update model and form_data so routing uses the fallback model's type
model = request.app.state.MODELS[fallback_model_id]
form_data["model"] = fallback_model_id
else:
raise Exception("Model not found")
else:
@@ -1745,9 +1806,12 @@ async def chat_completion(
"local:"
): # temporary chats are not stored
# Verify chat ownership
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None and user.role != "admin": # admins can access any chat
# Verify chat ownership — lightweight EXISTS check avoids
# deserializing the full chat JSON blob just to confirm the row exists
if (
not Chats.is_chat_owner(metadata["chat_id"], user.id)
and user.role != "admin"
): # admins can access any chat
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
@@ -1893,6 +1957,68 @@ generate_chat_completions = chat_completion
generate_chat_completion = chat_completion
##################################
#
# Anthropic Messages API Compatible Endpoint
#
##################################
from open_webui.utils.anthropic import (
convert_anthropic_to_openai_payload,
convert_openai_to_anthropic_response,
openai_stream_to_anthropic_stream,
)
@app.post("/api/message")
@app.post("/api/v1/messages") # Anthropic Messages API compatible endpoint
async def generate_messages(
request: Request,
form_data: dict,
user=Depends(get_verified_user),
):
"""
Anthropic Messages API compatible endpoint.
Accepts the Anthropic Messages API format, converts internally to OpenAI
Chat Completions format, routes through the existing chat completion
pipeline, then converts the response back to Anthropic Messages format.
Supports both streaming and non-streaming requests.
All models configured in Open WebUI are accessible via this endpoint.
Authentication: Supports both standard Authorization header and
Anthropic's x-api-key header (via middleware translation).
"""
# Convert Anthropic payload to OpenAI format
requested_model = form_data.get("model", "")
openai_payload = convert_anthropic_to_openai_payload(form_data)
# Route through the existing chat_completion handler
response = await chat_completion(request, openai_payload, user)
# Convert response back to Anthropic format
if isinstance(response, StreamingResponse):
# Streaming response: wrap the generator to convert SSE format
return StreamingResponse(
openai_stream_to_anthropic_stream(
response.body_iterator, model=requested_model
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
elif isinstance(response, dict):
return convert_openai_to_anthropic_response(response, model=requested_model)
else:
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
return response
@app.post("/api/chat/completed")
async def chat_completed(
request: Request, form_data: dict, user=Depends(get_verified_user)
@@ -2042,6 +2168,7 @@ async def get_app_config(request: Request):
"enable_user_status": app.state.config.ENABLE_USER_STATUS,
"enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_admin_analytics": ENABLE_ADMIN_ANALYTICS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"enable_memories": app.state.config.ENABLE_MEMORIES,
@@ -2066,6 +2193,7 @@ async def get_app_config(request: Request):
"user_count": user_count,
"code": {
"engine": app.state.config.CODE_EXECUTION_ENGINE,
"interpreter_engine": app.state.config.CODE_INTERPRETER_ENGINE,
},
"audio": {
"tts": {
+9 -1
View File
@@ -1,8 +1,9 @@
import logging
from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
@@ -14,6 +15,13 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Re-apply JSON formatter after fileConfig replaces handlers.
if LOG_FORMAT == "json":
from open_webui.env import JSONFormatter
for handler in logging.root.handlers:
handler.setFormatter(JSONFormatter())
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
@@ -21,6 +21,39 @@ down_revision: Union[str, None] = "374d2f66af06"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
BATCH_SIZE = 5000
def _flush_batch(conn, table, batch):
"""
Insert a batch of messages, falling back to row-by-row on error.
Tries a single bulk insert first (fast path). If that fails (e.g. due to
a duplicate key), falls back to individual inserts wrapped in savepoints
so the rest of the batch can still succeed.
"""
savepoint = conn.begin_nested()
try:
conn.execute(sa.insert(table), batch)
savepoint.commit()
return len(batch), 0
except Exception:
savepoint.rollback()
# Batch failed - insert one-by-one to isolate the bad row(s)
inserted = 0
failed = 0
for msg in batch:
sp = conn.begin_nested()
try:
conn.execute(sa.insert(table).values(**msg))
sp.commit()
inserted += 1
except Exception as e:
sp.rollback()
failed += 1
log.warning(f"Failed to insert message {msg['id']}: {e}")
return inserted, failed
def upgrade() -> None:
# Step 1: Create table
@@ -88,18 +121,21 @@ def upgrade() -> None:
sa.column("updated_at", sa.BigInteger()),
)
# Fetch all chats (excluding shared chats which have user_id starting with 'shared-')
chats = conn.execute(
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where(
~chat_table.c.user_id.like("shared-%")
)
).fetchall()
# Stream rows instead of loading all into memory:
# - yield_per: fetches rows in chunks via cursor.fetchmany() (all backends)
# - stream_results: enables server-side cursors on PostgreSQL (no-op on SQLite)
result = conn.execute(
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat)
.where(~chat_table.c.user_id.like("shared-%"))
.execution_options(yield_per=1000, stream_results=True)
)
now = int(time.time())
messages_inserted = 0
messages_failed = 0
messages_batch = []
total_inserted = 0
total_failed = 0
for chat_row in chats:
for chat_row in result:
chat_id = chat_row[0]
user_id = chat_row[1]
chat_data = chat_row[2]
@@ -127,6 +163,11 @@ def upgrade() -> None:
timestamp = message.get("timestamp", now)
try:
timestamp = int(float(timestamp))
except Exception as e:
timestamp = now
# Normalize timestamp: convert ms to seconds, validate range
if timestamp > 10_000_000_000:
timestamp = timestamp // 1000
@@ -134,39 +175,49 @@ def upgrade() -> None:
if timestamp < 1577836800 or timestamp > now + 86400:
timestamp = now
# Use savepoint to allow individual insert failures without aborting transaction
savepoint = conn.begin_nested()
try:
conn.execute(
sa.insert(chat_message_table).values(
id=f"{chat_id}-{message_id}",
chat_id=chat_id,
user_id=user_id,
role=role,
parent_id=message.get("parentId"),
content=message.get("content"),
output=message.get("output"),
model_id=message.get("model"),
files=message.get("files"),
sources=message.get("sources"),
embeds=message.get("embeds"),
done=message.get("done", True),
status_history=message.get("statusHistory"),
error=message.get("error"),
created_at=timestamp,
updated_at=timestamp,
)
messages_batch.append(
{
"id": f"{chat_id}-{message_id}",
"chat_id": chat_id,
"user_id": user_id,
"role": role,
"parent_id": message.get("parentId"),
"content": message.get("content"),
"output": message.get("output"),
"model_id": message.get("model"),
"files": message.get("files"),
"sources": message.get("sources"),
"embeds": message.get("embeds"),
"done": message.get("done", True),
"status_history": message.get("statusHistory"),
"error": message.get("error"),
"usage": message.get("usage"),
"created_at": timestamp,
"updated_at": timestamp,
}
)
# Flush batch when full
if len(messages_batch) >= BATCH_SIZE:
inserted, failed = _flush_batch(
conn, chat_message_table, messages_batch
)
savepoint.commit()
messages_inserted += 1
except Exception as e:
savepoint.rollback()
messages_failed += 1
log.warning(f"Failed to insert message {message_id}: {e}")
continue
total_inserted += inserted
total_failed += failed
if total_inserted % 50000 < BATCH_SIZE:
log.info(
f"Migration progress: {total_inserted} messages inserted..."
)
messages_batch.clear()
# Flush remaining messages
if messages_batch:
inserted, failed = _flush_batch(conn, chat_message_table, messages_batch)
total_inserted += inserted
total_failed += failed
log.info(
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
f"Backfilled {total_inserted} messages into chat_message table ({total_failed} failed)"
)
+63 -1
View File
@@ -204,6 +204,43 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
return False
def has_user_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes any non-wildcard user grant.
"""
for grant in normalize_access_grants(access_grants):
if grant["principal_type"] == "user" and grant["principal_id"] != "*":
return True
return False
def strip_user_access_grants(access_grants: Optional[list]) -> list:
"""
Remove all non-wildcard user grants from the list.
Keeps group grants and the public wildcard (user:*) intact.
"""
if not access_grants:
return []
return [
grant
for grant in access_grants
if not (
(
grant.get("principal_type")
if isinstance(grant, dict)
else getattr(grant, "principal_type", None)
)
== "user"
and (
grant.get("principal_id")
if isinstance(grant, dict)
else getattr(grant, "principal_id", None)
)
!= "*"
)
]
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
@@ -402,7 +439,7 @@ class AccessGrantsTable:
results = []
for grant_dict in normalized_grants:
grant = AccessGrant(
id=grant_dict["id"],
id=str(uuid.uuid4()),
resource_type=resource_type,
resource_id=resource_id,
principal_type=grant_dict["principal_type"],
@@ -456,6 +493,31 @@ class AccessGrantsTable:
)
return [AccessGrantModel.model_validate(g) for g in grants]
def get_grants_by_resources(
self,
resource_type: str,
resource_ids: list[str],
db: Optional[Session] = None,
) -> dict[str, list[AccessGrantModel]]:
"""Batch-fetch grants for multiple resources. Returns {resource_id: [grants]}."""
if not resource_ids:
return {}
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id.in_(resource_ids),
)
.all()
)
result: dict[str, list[AccessGrantModel]] = {
rid: [] for rid in resource_ids
}
for g in grants:
result[g.resource_id].append(AccessGrantModel.model_validate(g))
return result
def has_access(
self,
user_id: str,
+5 -2
View File
@@ -146,7 +146,7 @@ class AuthsTable:
def authenticate_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_api_key: {api_key}")
log.info(f"authenticate_user_by_api_key")
# if no api_key, return None
if not api_key:
return None
@@ -197,7 +197,10 @@ class AuthsTable:
with get_db_context(db) as db:
result = db.query(Auth).filter_by(id=id).update({"email": email})
db.commit()
return True if result == 1 else False
if result == 1:
Users.update_user_by_id(id, {"email": email}, db=db)
return True
return False
except Exception:
return False
+40 -6
View File
@@ -261,13 +261,19 @@ class ChannelTable:
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
def _to_channel_model(
self, channel: Channel, db: Optional[Session] = None
self,
channel: Channel,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> ChannelModel:
channel_data = ChannelModel.model_validate(channel).model_dump(
exclude={"access_grants"}
)
access_grants = self._get_access_grants(channel_data["id"], db=db)
channel_data["access_grants"] = access_grants
channel_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(channel_data["id"], db=db)
)
return ChannelModel.model_validate(channel_data)
def _collect_unique_user_ids(
@@ -368,7 +374,18 @@ class ChannelTable:
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
with get_db_context(db) as db:
channels = db.query(Channel).all()
return [self._to_channel_model(channel, db=db) for channel in channels]
channel_ids = [channel.id for channel in channels]
grants_map = AccessGrants.get_grants_by_resources(
"channel", channel_ids, db=db
)
return [
self._to_channel_model(
channel,
access_grants=grants_map.get(channel.id, []),
db=db,
)
for channel in channels
]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
return AccessGrants.has_permission_filter(
@@ -417,7 +434,14 @@ class ChannelTable:
standard_channels = query.all()
all_channels = membership_channels + standard_channels
return [self._to_channel_model(c, db=db) for c in all_channels]
channel_ids = [c.id for c in all_channels]
grants_map = AccessGrants.get_grants_by_resources(
"channel", channel_ids, db=db
)
return [
self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db)
for c in all_channels
]
def get_dm_channel_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
@@ -724,7 +748,17 @@ class ChannelTable:
)
channel_ids = [cf.channel_id for cf in channel_files]
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
return [self._to_channel_model(channel, db=db) for channel in channels]
grants_map = AccessGrants.get_grants_by_resources(
"channel", channel_ids, db=db
)
return [
self._to_channel_model(
channel,
access_grants=grants_map.get(channel.id, []),
db=db,
)
for channel in channels
]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str, db: Optional[Session] = None
+3 -1
View File
@@ -292,9 +292,11 @@ class ChatMessageTable:
query = query.filter(ChatMessage.created_at <= end_date)
# Group by chat_id and order by most recent message in each chat
# Secondary sort on chat_id ensures deterministic pagination
# (prevents duplicates across pages when timestamps tie)
chat_ids = (
query.group_by(ChatMessage.chat_id)
.order_by(func.max(ChatMessage.created_at).desc())
.order_by(func.max(ChatMessage.created_at).desc(), ChatMessage.chat_id)
.offset(skip)
.limit(limit)
.all()
+113 -29
View File
@@ -456,11 +456,11 @@ class ChatTable:
return ChatModel.model_validate(chat)
def get_chat_title_by_id(self, id: str) -> Optional[str]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get("title", "New Chat")
with get_db_context() as db:
result = db.query(Chat.title).filter_by(id=id).first()
if result is None:
return None
return result[0] or "New Chat"
def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
chat = self.get_chat_by_id(id)
@@ -489,6 +489,7 @@ class ChatTable:
if isinstance(message.get("content"), str):
message["content"] = sanitize_text_for_db(message["content"])
user_id = chat.user_id
chat = chat.chat
history = chat.get("history", {})
@@ -509,7 +510,7 @@ class ChatTable:
ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=self.get_chat_by_id(id).user_id,
user_id=user_id,
data=history["messages"][message_id],
)
except Exception as e:
@@ -630,7 +631,9 @@ class ChatTable:
with get_db_context(db) as db:
# Use subquery to delete chat_messages for shared chats
shared_chat_id_subquery = (
db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery()
db.query(Chat.id)
.filter_by(user_id=f"shared-{chat_id}")
.scalar_subquery()
)
db.query(ChatMessage).filter(
ChatMessage.chat_id.in_(shared_chat_id_subquery)
@@ -713,7 +716,7 @@ class ChatTable:
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id, archived=True)
@@ -731,13 +734,17 @@ class ChatTable:
raise ValueError("Invalid order_by field")
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
query = query.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
query = query.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
query = query.order_by(Chat.updated_at.desc(), Chat.id)
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at
)
if skip:
query = query.offset(skip)
@@ -745,7 +752,17 @@ class ChatTable:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
ChatTitleIdResponse.model_validate(
{
"id": chat[0],
"title": chat[1],
"updated_at": chat[2],
"created_at": chat[3],
}
)
for chat in all_chats
]
def get_shared_chat_list_by_user_id(
self,
@@ -754,7 +771,7 @@ class ChatTable:
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
) -> list[SharedChatResponse]:
with get_db_context(db) as db:
query = (
@@ -776,13 +793,23 @@ class ChatTable:
raise ValueError("Invalid order_by field")
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
query = query.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
query = query.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
query = query.order_by(Chat.updated_at.desc(), Chat.id)
# Select only the columns needed for SharedChatResponse
# to avoid loading the heavy chat JSON blob
query = query.with_entities(
Chat.id,
Chat.title,
Chat.share_id,
Chat.updated_at,
Chat.created_at,
)
if skip:
query = query.offset(skip)
@@ -790,7 +817,18 @@ class ChatTable:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
SharedChatResponse.model_validate(
{
"id": chat[0],
"title": chat[1],
"share_id": chat[2],
"updated_at": chat[3],
"created_at": chat[4],
}
)
for chat in all_chats
]
def get_chat_list_by_user_id(
self,
@@ -816,13 +854,13 @@ class ChatTable:
if order_by and direction and getattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
query = query.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
query = query.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
query = query.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
query = query.offset(skip)
@@ -854,7 +892,7 @@ class ChatTable:
if not include_archived:
query = query.filter_by(archived=False)
query = query.order_by(Chat.updated_at.desc()).with_entities(
query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at
)
@@ -938,6 +976,37 @@ class ChatTable:
except Exception:
return None
def is_chat_owner(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
"""
Lightweight ownership check — uses EXISTS subquery instead of loading
the full Chat row (which includes the potentially large JSON blob).
"""
try:
with get_db_context(db) as db:
return db.query(
exists().where(and_(Chat.id == id, Chat.user_id == user_id))
).scalar()
except Exception:
return False
def get_chat_folder_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[str]:
"""
Fetch only the folder_id column for a chat, without loading the full
JSON blob. Returns None if chat doesn't exist or doesn't belong to user.
"""
try:
with get_db_context(db) as db:
result = (
db.query(Chat.folder_id).filter_by(id=id, user_id=user_id).first()
)
return result[0] if result else None
except Exception:
return None
def get_chats(
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[ChatModel]:
@@ -970,14 +1039,18 @@ class ChatTable:
if order_by and direction:
if hasattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
query = query.order_by(
getattr(Chat, order_by).asc(), Chat.id
)
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
query = query.order_by(
getattr(Chat, order_by).desc(), Chat.id
)
else:
query = query.order_by(Chat.updated_at.desc())
query = query.order_by(Chat.updated_at.desc(), Chat.id)
else:
query = query.order_by(Chat.updated_at.desc())
query = query.order_by(Chat.updated_at.desc(), Chat.id)
total = query.count()
@@ -997,14 +1070,25 @@ class ChatTable:
def get_pinned_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
with get_db_context(db) as db:
all_chats = (
db.query(Chat)
.filter_by(user_id=user_id, pinned=True, archived=False)
.order_by(Chat.updated_at.desc())
.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at)
)
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
ChatTitleIdResponse.model_validate(
{
"id": chat[0],
"title": chat[1],
"updated_at": chat[2],
"created_at": chat[3],
}
)
for chat in all_chats
]
def get_archived_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
@@ -1108,7 +1192,7 @@ class ChatTable:
if folder_ids:
query = query.filter(Chat.folder_id.in_(folder_ids))
query = query.order_by(Chat.updated_at.desc())
query = query.order_by(Chat.updated_at.desc(), Chat.id)
# Check if the database dialect is either 'sqlite' or 'postgresql'
dialect_name = db.bind.dialect.name
@@ -1229,7 +1313,7 @@ class ChatTable:
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
query = query.filter_by(archived=False)
query = query.order_by(Chat.updated_at.desc())
query = query.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
query = query.offset(skip)
+1
View File
@@ -71,6 +71,7 @@ class FolderForm(BaseModel):
name: str
data: Optional[dict] = None
meta: Optional[dict] = None
parent_id: Optional[str] = None
model_config = ConfigDict(extra="allow")
+22
View File
@@ -308,6 +308,28 @@ class FunctionsTable:
log.exception(f"Error getting function valves by id {id}: {e}")
return None
def get_function_valves_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> dict[str, dict]:
"""
Batch fetch valves for multiple functions in a single query.
Returns a dict mapping function_id -> valves dict.
Functions without valves are mapped to {}.
"""
if not ids:
return {}
try:
with get_db_context(db) as db:
functions = (
db.query(Function.id, Function.valves)
.filter(Function.id.in_(ids))
.all()
)
return {f.id: (f.valves if f.valves else {}) for f in functions}
except Exception as e:
log.exception(f"Error batch-fetching function valves: {e}")
return {}
def update_function_valves_by_id(
self, id: str, valves: dict, db: Optional[Session] = None
) -> Optional[FunctionValves]:
+20 -1
View File
@@ -6,6 +6,7 @@ import uuid
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION
from open_webui.models.files import FileMetadataResponse
@@ -130,13 +131,26 @@ class GroupListResponse(BaseModel):
class GroupTable:
def _ensure_default_share_config(self, group_data: dict) -> dict:
"""Ensure the group data dict has a default share config if not already set."""
if "data" not in group_data or group_data["data"] is None:
group_data["data"] = {}
if "config" not in group_data["data"]:
group_data["data"]["config"] = {}
if "share" not in group_data["data"]["config"]:
group_data["data"]["config"]["share"] = DEFAULT_GROUP_SHARE_PERMISSION
return group_data
def insert_new_group(
self, user_id: str, form_data: GroupForm, db: Optional[Session] = None
) -> Optional[GroupModel]:
with get_db_context(db) as db:
group_data = self._ensure_default_share_config(
form_data.model_dump(exclude_none=True)
)
group = GroupModel(
**{
**form_data.model_dump(exclude_none=True),
**group_data,
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
@@ -504,6 +518,11 @@ class GroupTable:
user_id=user_id,
name=group_name,
description="",
data={
"config": {
"share": DEFAULT_GROUP_SHARE_PERMISSION,
}
},
created_at=int(time.time()),
updated_at=int(time.time()),
)
+49 -6
View File
@@ -144,13 +144,18 @@ class KnowledgeTable:
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
def _to_knowledge_model(
self, knowledge: Knowledge, db: Optional[Session] = None
self,
knowledge: Knowledge,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
exclude={"access_grants"}
)
knowledge_data["access_grants"] = self._get_access_grants(
knowledge_data["id"], db=db
knowledge_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(knowledge_data["id"], db=db)
)
return KnowledgeModel.model_validate(knowledge_data)
@@ -192,9 +197,13 @@ class KnowledgeTable:
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
)
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
knowledge_ids = [knowledge.id for knowledge in all_knowledge]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
knowledge_bases = []
for knowledge in all_knowledge:
@@ -202,7 +211,11 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**self._to_knowledge_model(knowledge, db=db).model_dump(),
**self._to_knowledge_model(
knowledge,
access_grants=grants_map.get(knowledge.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -261,13 +274,20 @@ class KnowledgeTable:
items = query.all()
knowledge_ids = [kb.id for kb, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
knowledge_bases = []
for knowledge_base, user in items:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**self._to_knowledge_model(
knowledge_base, db=db
knowledge_base,
access_grants=grants_map.get(knowledge_base.id, []),
db=db,
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
@@ -440,8 +460,16 @@ class KnowledgeTable:
.filter(KnowledgeFile.file_id == file_id)
.all()
)
knowledge_ids = [k.id for k in knowledges]
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
return [
self._to_knowledge_model(knowledge, db=db)
self._to_knowledge_model(
knowledge,
access_grants=grants_map.get(knowledge.id, []),
db=db,
)
for knowledge in knowledges
]
except Exception:
@@ -585,6 +613,21 @@ class KnowledgeTable:
except Exception:
return None
def has_file(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
"""Check whether a file belongs to a knowledge base."""
try:
with get_db_context(db) as db:
return (
db.query(KnowledgeFile)
.filter_by(knowledge_id=knowledge_id, file_id=file_id)
.first()
is not None
)
except Exception:
return False
def remove_file_from_knowledge_by_id(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
+63 -10
View File
@@ -144,11 +144,20 @@ class ModelsTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
def _to_model_model(
self,
model: Model,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> ModelModel:
model_data = ModelModel.model_validate(model).model_dump(
exclude={"access_grants"}
)
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
model_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(model_data["id"], db=db)
)
return ModelModel.model_validate(model_data)
def insert_new_model(
@@ -181,8 +190,14 @@ class ModelsTable:
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
all_models = db.query(Model).all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
return [
self._to_model_model(model, db=db) for model in db.query(Model).all()
self._to_model_model(
model, access_grants=grants_map.get(model.id, []), db=db
)
for model in all_models
]
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
@@ -190,9 +205,11 @@ class ModelsTable:
all_models = db.query(Model).filter(Model.base_model_id != None).all()
user_ids = list(set(model.user_id for model in all_models))
model_ids = [model.id for model in all_models]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
models = []
for model in all_models:
@@ -200,7 +217,11 @@ class ModelsTable:
models.append(
ModelUserResponse.model_validate(
{
**self._to_model_model(model, db=db).model_dump(),
**self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -209,9 +230,14 @@ class ModelsTable:
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
all_models = db.query(Model).filter(Model.base_model_id == None).all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
return [
self._to_model_model(model, db=db)
for model in db.query(Model).filter(Model.base_model_id == None).all()
self._to_model_model(
model, access_grants=grants_map.get(model.id, []), db=db
)
for model in all_models
]
def get_models_by_user_id(
@@ -325,11 +351,18 @@ class ModelsTable:
items = query.all()
model_ids = [model.id for model, _ in items]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
models = []
for model, user in items:
models.append(
ModelUserResponse(
**self._to_model_model(model, db=db).model_dump(),
**self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -356,7 +389,18 @@ class ModelsTable:
try:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [self._to_model_model(model, db=db) for model in models]
model_ids = [model.id for model in models]
grants_map = AccessGrants.get_grants_by_resources(
"model", model_ids, db=db
)
return [
self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
)
for model in models
]
except Exception:
return []
@@ -465,9 +509,18 @@ class ModelsTable:
db.commit()
all_models = db.query(Model).all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources(
"model", model_ids, db=db
)
return [
self._to_model_model(model, db=db)
for model in db.query(Model).all()
self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
)
for model in all_models
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")
+35 -5
View File
@@ -93,9 +93,18 @@ class NoteTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
def _to_note_model(
self,
note: Note,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> NoteModel:
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
note_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(note_data["id"], db=db)
)
return NoteModel.model_validate(note_data)
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
@@ -142,7 +151,14 @@ class NoteTable:
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [self._to_note_model(note, db=db) for note in notes]
note_ids = [note.id for note in notes]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
return [
self._to_note_model(
note, access_grants=grants_map.get(note.id, []), db=db
)
for note in notes
]
def search_notes(
self,
@@ -227,11 +243,18 @@ class NoteTable:
items = query.all()
note_ids = [note.id for note, _ in items]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**self._to_note_model(note, db=db).model_dump(),
**self._to_note_model(
note,
access_grants=grants_map.get(note.id, []),
db=db,
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -266,7 +289,14 @@ class NoteTable:
query = query.limit(limit)
notes = query.all()
return [self._to_note_model(note, db=db) for note in notes]
note_ids = [note.id for note in notes]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
return [
self._to_note_model(
note, access_grants=grants_map.get(note.id, []), db=db
)
for note in notes
]
def get_note_by_id(
self, id: str, db: Optional[Session] = None
@@ -135,6 +135,7 @@ class OAuthSessionTable:
db.refresh(result)
if result:
db.expunge(result) # Detach so dict swap is never flushed
result.token = token # Return decrypted token
return OAuthSessionModel.model_validate(result)
else:
@@ -151,6 +152,7 @@ class OAuthSessionTable:
with get_db_context(db) as db:
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
@@ -171,6 +173,7 @@ class OAuthSessionTable:
.first()
)
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
@@ -188,9 +191,11 @@ class OAuthSessionTable:
session = (
db.query(OAuthSession)
.filter_by(provider=provider, user_id=user_id)
.order_by(OAuthSession.created_at.desc())
.first()
)
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
@@ -210,6 +215,7 @@ class OAuthSessionTable:
results = []
for session in sessions:
try:
db.expunge(session)
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
except Exception as e:
@@ -244,6 +250,7 @@ class OAuthSessionTable:
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
+55 -36
View File
@@ -97,12 +97,19 @@ class PromptsTable:
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
def _to_prompt_model(
self, prompt: Prompt, db: Optional[Session] = None
self,
prompt: Prompt,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(
exclude={"access_grants"}
)
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
prompt_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(prompt_data["id"], db=db)
)
return PromptModel.model_validate(prompt_data)
def insert_new_prompt(
@@ -206,9 +213,13 @@ class PromptsTable:
)
user_ids = list(set(prompt.user_id for prompt in all_prompts))
prompt_ids = [prompt.id for prompt in all_prompts]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources(
"prompt", prompt_ids, db=db
)
prompts = []
for prompt in all_prompts:
@@ -216,7 +227,11 @@ class PromptsTable:
prompts.append(
PromptUserResponse.model_validate(
{
**self._to_prompt_model(prompt, db=db).model_dump(),
**self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -259,7 +274,6 @@ class PromptsTable:
# Join with User table for user filtering and sorting
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = query.filter(Prompt.is_active == True)
if filter:
query_key = filter.get("query")
@@ -330,11 +344,20 @@ class PromptsTable:
items = query.all()
prompt_ids = [prompt.id for prompt, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"prompt", prompt_ids, db=db
)
prompts = []
for prompt, user in items:
prompts.append(
PromptUserResponse(
**self._to_prompt_model(prompt, db=db).model_dump(),
**self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -562,43 +585,24 @@ class PromptsTable:
except Exception:
return None
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
"""Soft delete a prompt by setting is_active to False."""
def toggle_prompt_active(
self, prompt_id: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
"""Toggle the is_active flag on a prompt."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.is_active = not prompt.is_active
prompt.updated_at = int(time.time())
db.commit()
return True
return False
db.refresh(prompt)
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return False
return None
def hard_delete_prompt_by_command(
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Permanently delete a prompt and its history."""
@@ -609,8 +613,23 @@ class PromptsTable:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
# Delete prompt
db.query(Prompt).filter_by(command=command).delete()
db.delete(prompt)
db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
"""Permanently delete a prompt and its history."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
db.delete(prompt)
db.commit()
return True
return False
+28 -4
View File
@@ -110,11 +110,20 @@ class SkillsTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
def _to_skill_model(
self,
skill: Skill,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> SkillModel:
skill_data = SkillModel.model_validate(skill).model_dump(
exclude={"access_grants"}
)
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
skill_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(skill_data["id"], db=db)
)
return SkillModel.model_validate(skill_data)
def insert_new_skill(
@@ -172,9 +181,11 @@ class SkillsTable:
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
user_ids = list(set(skill.user_id for skill in all_skills))
skill_ids = [skill.id for skill in all_skills]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("skill", skill_ids, db=db)
skills = []
for skill in all_skills:
@@ -182,7 +193,11 @@ class SkillsTable:
skills.append(
SkillUserModel.model_validate(
{
**self._to_skill_model(skill, db=db).model_dump(),
**self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -267,11 +282,20 @@ class SkillsTable:
items = query.all()
skill_ids = [skill.id for skill, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"skill", skill_ids, db=db
)
skills = []
for skill, user in items:
skills.append(
SkillUserResponse(
**self._to_skill_model(skill, db=db).model_dump(),
**self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
+32 -8
View File
@@ -2,7 +2,7 @@ import logging
import time
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
@@ -100,9 +100,18 @@ class ToolsTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
def _to_tool_model(
self,
tool: Tool,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> ToolModel:
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
tool_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(tool_data["id"], db=db)
)
return ToolModel.model_validate(tool_data)
def insert_new_tool(
@@ -147,14 +156,21 @@ class ToolsTable:
except Exception:
return None
def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]:
def get_tools(
self, defer_content: bool = False, db: Optional[Session] = None
) -> list[ToolUserModel]:
with get_db_context(db) as db:
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
query = db.query(Tool).order_by(Tool.updated_at.desc())
if defer_content:
query = query.options(defer(Tool.content), defer(Tool.specs))
all_tools = query.all()
user_ids = list(set(tool.user_id for tool in all_tools))
tool_ids = [tool.id for tool in all_tools]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("tool", tool_ids, db=db)
tools = []
for tool in all_tools:
@@ -162,7 +178,11 @@ class ToolsTable:
tools.append(
ToolUserModel.model_validate(
{
**self._to_tool_model(tool, db=db).model_dump(),
**self._to_tool_model(
tool,
access_grants=grants_map.get(tool.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -170,9 +190,13 @@ class ToolsTable:
return tools
def get_tools_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
self,
user_id: str,
permission: str = "write",
defer_content: bool = False,
db: Optional[Session] = None,
) -> list[ToolUserModel]:
tools = self.get_tools(db=db)
tools = self.get_tools(defer_content=defer_content, db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
+50 -11
View File
@@ -88,6 +88,14 @@ def get_content_from_url(request, url: str) -> str:
return content, docs
CHUNK_HASH_KEY = "_chunk_hash"
def _content_hash(text: str) -> str:
"""SHA-256 hash of text, used as a stable chunk identifier for RRF dedup."""
return hashlib.sha256(text.encode()).hexdigest()
class VectorSearchRetriever(BaseRetriever):
collection_name: Any
embedding_function: Any
@@ -126,9 +134,11 @@ class VectorSearchRetriever(BaseRetriever):
results = []
for idx in range(len(ids)):
metadata = metadatas[idx]
metadata[CHUNK_HASH_KEY] = _content_hash(documents[idx])
results.append(
Document(
metadata=metadatas[idx],
metadata=metadata,
page_content=documents[idx],
)
)
@@ -240,15 +250,21 @@ async def query_doc_with_hybrid_search(
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
original_texts = collection_result.documents[0]
bm25_metadatas = [
{**meta, CHUNK_HASH_KEY: _content_hash(original_texts[idx])}
for idx, meta in enumerate(collection_result.metadatas[0])
]
bm25_texts = (
get_enriched_texts(collection_result)
if enable_enriched_texts
else collection_result.documents[0]
else original_texts
)
bm25_retriever = BM25Retriever.from_texts(
texts=bm25_texts,
metadatas=collection_result.metadatas[0],
metadatas=bm25_metadatas,
)
bm25_retriever.k = k
@@ -258,18 +274,24 @@ async def query_doc_with_hybrid_search(
top_k=k,
)
# Use CHUNK_HASH_KEY for dedup so enriched BM25 texts don't defeat RRF
if hybrid_bm25_weight <= 0:
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_search_retriever], weights=[1.0]
retrievers=[vector_search_retriever],
weights=[1.0],
id_key=CHUNK_HASH_KEY,
)
elif hybrid_bm25_weight >= 1:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever], weights=[1.0]
retrievers=[bm25_retriever],
weights=[1.0],
id_key=CHUNK_HASH_KEY,
)
else:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_search_retriever],
weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight],
id_key=CHUNK_HASH_KEY,
)
compressor = RerankCompressor(
@@ -292,7 +314,7 @@ async def query_doc_with_hybrid_search(
# retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker
if k < k_reranker:
sorted_items = sorted(
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
zip(distances, documents, metadatas), key=lambda x: x[0], reverse=True
)
sorted_items = sorted_items[:k]
@@ -803,6 +825,7 @@ def get_embedding_function(
embedding_batch_size,
azure_api_version=None,
enable_async=True,
concurrent_requests=0,
) -> Awaitable:
if embedding_engine == "":
# Sentence transformers: CPU-bound sync operation
@@ -844,11 +867,25 @@ def get_embedding_function(
log.debug(
f"generate_multiple_async: Processing {len(batches)} batches in parallel"
)
# Execute all batches in parallel
tasks = [
embedding_function(batch, prefix=prefix, user=user)
for batch in batches
]
# Use semaphore to limit concurrent embedding API requests
# 0 = unlimited (no semaphore)
if concurrent_requests:
semaphore = asyncio.Semaphore(concurrent_requests)
async def generate_batch_with_semaphore(batch):
async with semaphore:
return await embedding_function(
batch, prefix=prefix, user=user
)
tasks = [
generate_batch_with_semaphore(batch) for batch in batches
]
else:
tasks = [
embedding_function(batch, prefix=prefix, user=user)
for batch in batches
]
batch_results = await asyncio.gather(*tasks)
else:
log.debug(
@@ -1269,6 +1306,8 @@ def get_model_path(model: str, update_model: bool = False):
return model_repo_path
except Exception as e:
log.exception(f"Cannot determine model snapshot path: {e}")
if OFFLINE_MODE:
raise
return model
+73
View File
@@ -0,0 +1,73 @@
import logging
from typing import Optional, List
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
log = logging.getLogger(__name__)
def search_youcom(
api_key: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
language: str = "EN",
) -> List[SearchResult]:
"""Search using You.com's YDC Index API and return the results as a list of SearchResult objects.
Args:
api_key (str): A You.com API key
query (str): The query to search for
count (int): Maximum number of results to return
filter_list (list[str], optional): Domain filter list
language (str): Language code for search results (default: "EN")
"""
url = "https://ydc-index.io/v1/search"
headers = {
"Accept": "application/json",
"X-API-KEY": api_key,
}
params = {
"query": query,
"count": count,
"language": language,
}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()
results = json_response.get("results", {}).get("web", [])
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result["url"],
title=result.get("title"),
snippet=_build_snippet(result),
)
for result in results[:count]
]
def _build_snippet(result: dict) -> str:
"""Combine the description and snippets list into a single string.
The You.com API returns a short ``description`` plus a ``snippets``
list with richer passages. Merging them gives downstream retrieval
(embedding, BM25, bypass-loader context) the most content to work with.
"""
parts: list[str] = []
description = result.get("description")
if description:
parts.append(description)
snippets = result.get("snippets")
if snippets and isinstance(snippets, list):
parts.extend(snippets)
return "\n\n".join(parts)
+2 -2
View File
@@ -278,7 +278,7 @@ class ModelChatsResponse(BaseModel):
total: int
@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse)
@router.get("/models/{model_id:path}/chats", response_model=ModelChatsResponse)
async def get_model_chats(
model_id: str,
start_date: Optional[int] = Query(None),
@@ -367,7 +367,7 @@ class ModelOverviewResponse(BaseModel):
tags: list[TagEntry]
@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse)
@router.get("/models/{model_id:path}/overview", response_model=ModelOverviewResponse)
async def get_model_overview(
model_id: str,
days: int = Query(30, description="Number of days of history (0 for all)"),
+9 -3
View File
@@ -1194,7 +1194,9 @@ def transcription(
)
try:
ext = file.filename.split(".")[-1]
safe_name = os.path.basename(file.filename) if file.filename else ""
ext = safe_name.rsplit(".", 1)[-1] if "." in safe_name else ""
id = uuid.uuid4()
filename = f"{id}.{ext}"
@@ -1204,6 +1206,10 @@ def transcription(
os.makedirs(file_dir, exist_ok=True)
file_path = f"{file_dir}/{filename}"
# Defense-in-depth: ensure resolved path stays within intended directory
if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
raise ValueError("Invalid file path detected")
with open(file_path, "wb") as f:
f.write(contents)
@@ -1225,7 +1231,7 @@ def transcription(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
detail="Transcription failed.",
)
except Exception as e:
@@ -1233,7 +1239,7 @@ def transcription(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
detail="Transcription failed.",
)
+13 -11
View File
@@ -700,8 +700,9 @@ async def signup_handler(
Returns the newly created UserModel.
Raises HTTPException on failure.
"""
has_users = Users.has_users(db=db)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
# Insert with default role first to avoid TOCTOU race on first signup.
# If has_users() is checked before insert, concurrent requests during
# first-user registration can all see an empty table and each get admin.
hashed = get_password_hash(password)
user = Auths.insert_new_auth(
@@ -709,12 +710,19 @@ async def signup_handler(
password=hashed,
name=name,
profile_image_url=profile_image_url,
role=role,
role=request.app.state.config.DEFAULT_USER_ROLE,
db=db,
)
if not user:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
# Atomically check if this is the only user *after* the insert.
# Only the single user present at this point should become admin.
if Users.get_num_users(db=db) == 1:
Users.update_user_role_by_id(user.id, "admin", db=db)
user = Users.get_user_by_id(user.id, db=db)
request.app.state.config.ENABLE_SIGNUP = False
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
@@ -727,10 +735,6 @@ async def signup_handler(
},
)
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
@@ -1153,8 +1157,6 @@ async def update_ldap_server(
"host",
"attribute_for_mail",
"attribute_for_username",
"app_dn",
"app_dn_password",
"search_base",
]
for key in required_fields:
@@ -1169,8 +1171,8 @@ async def update_ldap_server(
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
form_data.attribute_for_username
)
request.app.state.config.LDAP_APP_DN = form_data.app_dn
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password
request.app.state.config.LDAP_APP_DN = form_data.app_dn or ""
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or ""
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
+3 -1
View File
@@ -1578,7 +1578,9 @@ async def update_message_by_id(
if (
user.role != "admin"
and message.user_id != user.id
and not channel_has_access(user.id, channel, permission="read", db=db)
and not channel_has_access(
user.id, channel, permission="write", strict=False, db=db
)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+16 -29
View File
@@ -723,10 +723,7 @@ async def get_chat_list_by_folder_id(
async def get_user_pinned_chats(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return [
ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_pinned_chats_by_user_id(user.id, db=db)
]
return Chats.get_pinned_chats_by_user_id(user.id, db=db)
############################
@@ -821,18 +818,13 @@ async def get_archived_session_user_chat_list(
if direction:
filter["direction"] = direction
chat_list = [
ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
]
return chat_list
return Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
############################
@@ -887,18 +879,13 @@ async def get_shared_session_user_chat_list(
if direction:
filter["direction"] = direction
chat_list = [
SharedChatResponse(**chat.model_dump())
for chat in Chats.get_shared_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
]
return chat_list
return Chats.get_shared_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
############################
@@ -1147,7 +1134,7 @@ async def delete_chat_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = Chats.get_chat_by_id(id, db=db)
chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+54
View File
@@ -15,6 +15,7 @@ from open_webui.utils.tools import (
get_tool_server_data,
get_tool_server_url,
set_tool_servers,
set_terminal_servers,
)
from open_webui.utils.mcp.client import MCPClient
from open_webui.models.oauth_sessions import OAuthSessions
@@ -214,6 +215,51 @@ async def set_tool_servers_config(
}
class TerminalServerConnection(BaseModel):
id: Optional[str] = ""
name: Optional[str] = ""
enabled: Optional[bool] = True
url: str
path: Optional[str] = "/openapi.json"
key: Optional[str] = ""
auth_type: Optional[str] = "bearer"
config: Optional[dict] = None
model_config = ConfigDict(extra="allow")
class TerminalServersConfigForm(BaseModel):
TERMINAL_SERVER_CONNECTIONS: list[TerminalServerConnection]
@router.get("/terminal_servers")
async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)):
return {
"TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
}
@router.post("/terminal_servers")
async def set_terminal_servers_config(
request: Request,
form_data: TerminalServersConfigForm,
user=Depends(get_admin_user),
):
request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS
]
await set_terminal_servers(request)
return {
"TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
}
@router.post("/tool_servers/verify")
async def verify_tool_servers_config(
request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)
@@ -467,6 +513,8 @@ class ModelsConfigForm(BaseModel):
DEFAULT_MODELS: Optional[str]
DEFAULT_PINNED_MODELS: Optional[str]
MODEL_ORDER_LIST: Optional[list[str]]
DEFAULT_MODEL_METADATA: Optional[dict] = None
DEFAULT_MODEL_PARAMS: Optional[dict] = None
@router.get("/models", response_model=ModelsConfigForm)
@@ -475,6 +523,8 @@ async def get_models_config(request: Request, user=Depends(get_admin_user)):
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
}
@@ -485,10 +535,14 @@ async def set_models_config(
request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS
request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA
request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS
return {
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
}
+43 -67
View File
@@ -47,6 +47,7 @@ from open_webui.routers.audio import transcribe
from open_webui.storage.provider import Storage
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel
@@ -56,70 +57,34 @@ log = logging.getLogger(__name__)
router = APIRouter()
############################
# Check if the current user has access to a file through any knowledge bases the user may be in.
############################
# TODO: Optimize this function to use the knowledge_file table for faster lookups.
def has_access_to_file(
file_id: Optional[str],
access_type: str,
user=Depends(get_verified_user),
db: Optional[Session] = None,
) -> bool:
file = Files.get_file_by_id(file_id, db=db)
log.debug(f"Checking if user has {access_type} access to file")
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
):
return True
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type, db=db
)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
return True
# Check if the file is associated with any channels the user has access to
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
if access_type == "read" and channels:
return True
# Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats
chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
if chats:
return True
return False
from open_webui.utils.access_control.files import has_access_to_file
############################
# Upload File
############################
def _is_text_file(file_path: str, chunk_size: int = 8192) -> bool:
"""Check if a file is likely a text file by reading a chunk and validating UTF-8.
This catches files whose extensions are mis-mapped by mimetypes/browsers
(e.g. TypeScript .ts → video/mp2t) without maintaining an extension whitelist.
"""
try:
resolved = Storage.get_file(file_path)
with open(resolved, "rb") as f:
chunk = f.read(chunk_size)
if not chunk:
return False
# Null bytes are a strong indicator of binary content
if b"\x00" in chunk:
return False
chunk.decode("utf-8")
return True
except (UnicodeDecodeError, Exception):
return False
def process_uploaded_file(
request,
file,
@@ -131,14 +96,19 @@ def process_uploaded_file(
):
def _process_handler(db_session):
try:
if file.content_type:
content_type = file.content_type
# Detect mis-labeled text files (e.g. .ts → video/mp2t)
if content_type and content_type.startswith(("image/", "video/")):
if _is_text_file(file_path):
content_type = "text/plain"
if content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if strict_match_mime_type(
stt_supported_content_types, file.content_type
):
if strict_match_mime_type(stt_supported_content_types, content_type):
file_path_processed = Storage.get_file(file_path)
result = transcribe(
request, file_path_processed, file_metadata, user
@@ -152,7 +122,7 @@ def process_uploaded_file(
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
elif (not content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(
@@ -163,7 +133,7 @@ def process_uploaded_file(
)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
f"File type {content_type} is not supported for processing"
)
else:
log.info(
@@ -362,7 +332,7 @@ async def list_files(
content: bool = Query(True),
db: Session = Depends(get_session),
):
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
files = Files.get_files(db=db)
else:
files = Files.get_files_by_user_id(user.id, db=db)
@@ -398,8 +368,10 @@ async def search_files(
Search for files by filename with support for wildcard patterns.
Uses SQL-based filtering with pagination for better performance.
"""
# Determine user_id: null for admin (search all), user.id for regular users
user_id = None if user.role == "admin" else user.id
# Determine user_id: null for admin with bypass (search all), user.id otherwise
user_id = (
None if (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) else user.id
)
# Use optimized database query with pagination
files = Files.search_files(
@@ -689,6 +661,8 @@ async def get_file_content_by_id(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
except HTTPException as e:
raise e
except Exception as e:
log.exception(e)
log.error("Error getting file content")
@@ -740,6 +714,8 @@ async def get_html_file_content_by_id(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
except HTTPException as e:
raise e
except Exception as e:
log.exception(e)
log.error("Error getting file content")
+7 -3
View File
@@ -119,7 +119,7 @@ def create_folder(
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
None, user.id, form_data.name, db=db
form_data.parent_id, user.id, form_data.name, db=db
)
if folder:
@@ -129,7 +129,9 @@ def create_folder(
)
try:
folder = Folders.insert_new_folder(user.id, form_data, db=db)
folder = Folders.insert_new_folder(
user.id, form_data, form_data.parent_id, db=db
)
return folder
except Exception as e:
log.exception(e)
@@ -317,7 +319,9 @@ async def delete_folder_by_id(
folder = folders.pop()
if folder:
try:
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id, db=db)
folder_ids = Folders.delete_folder_by_id_and_user_id(
folder.id, user.id, db=db
)
for folder_id in folder_ids:
if delete_contents:
+8 -2
View File
@@ -641,7 +641,10 @@ async def image_generations(
for image in res["data"]:
if image_url := image.get("url", None):
image_data, content_type = get_image_data(image_url, headers)
image_data, content_type = get_image_data(
image_url,
{k: v for k, v in headers.items() if k != "Content-Type"},
)
else:
image_data, content_type = get_image_data(image["b64_json"])
@@ -993,7 +996,10 @@ async def image_edits(
images = []
for image in res["data"]:
if image_url := image.get("url", None):
image_data, content_type = get_image_data(image_url, headers)
image_data, content_type = get_image_data(
image_url,
{k: v for k, v in headers.items() if k != "Content-Type"},
)
else:
image_data, content_type = get_image_data(image["b64_json"])
+38 -43
View File
@@ -29,8 +29,8 @@ from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
@@ -251,7 +251,7 @@ async def create_new_knowledge(
user=Depends(get_verified_user),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (has_permission, insert_new_knowledge) manage their own sessions.
# Database operations (has_permission, filter_allowed_access_grants, insert_new_knowledge) manage their own sessions.
# This prevents holding a connection during embed_knowledge_base_metadata()
# which makes external embedding API calls (1-5+ seconds).
if user.role != "admin" and not has_permission(
@@ -262,17 +262,13 @@ async def create_new_knowledge(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Check if user can share publicly
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = []
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_knowledge",
)
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
@@ -482,17 +478,13 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Check if user can share publicly
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = []
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_knowledge",
)
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
@@ -554,24 +546,13 @@ async def update_knowledge_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_knowledge",
)
AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db)
@@ -764,6 +745,13 @@ def update_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Validate the file actually belongs to this knowledge base
if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Remove content from the vector database
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
@@ -838,6 +826,13 @@ def remove_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Validate the file actually belongs to this knowledge base
if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
Knowledges.remove_file_from_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id, db=db
)
+11 -21
View File
@@ -17,7 +17,7 @@ from open_webui.models.models import (
ModelAccessResponse,
Models,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
@@ -33,7 +33,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@@ -512,6 +512,7 @@ async def update_model_by_id(
class ModelAccessGrantsForm(BaseModel):
id: str
name: Optional[str] = None
access_grants: list[dict]
@@ -535,7 +536,7 @@ async def update_model_access_by_id(
model = Models.insert_new_model(
ModelForm(
id=form_data.id,
name=form_data.id,
name=form_data.name or form_data.id,
meta=ModelMeta(),
params=ModelParams(),
),
@@ -564,24 +565,13 @@ async def update_model_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_models",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_models",
)
AccessGrants.set_access_grants(
"model", form_data.id, form_data.access_grants, db=db
+30 -33
View File
@@ -27,8 +27,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@@ -36,6 +36,14 @@ log = logging.getLogger(__name__)
router = APIRouter()
def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optional[dict]:
if not data:
return data
md = (data.get("content") or {}).get("md") or ""
return {"content": {"md": md[:max_length]}}
############################
# GetNotes
############################
@@ -82,6 +90,7 @@ async def get_notes(
NoteUserResponse(
**{
**note.model_dump(),
"data": _truncate_note_data(note.data),
"user": UserResponse(**users[note.user_id].model_dump()),
}
)
@@ -135,7 +144,10 @@ async def search_notes(
filter["user_id"] = user.id
return Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
result = Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
for note in result.items:
note.data = _truncate_note_data(note.data)
return result
############################
@@ -271,18 +283,14 @@ async def update_note_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
# Check if user can share publicly
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
form_data.access_grants = []
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_notes",
db=db,
)
try:
note = Notes.update_note_by_id(id, form_data, db=db)
@@ -345,24 +353,13 @@ async def update_note_access_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_notes",
)
AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db)
+1 -1
View File
@@ -1176,7 +1176,7 @@ async def embeddings(
class GenerateCompletionForm(BaseModel):
model: str
prompt: str
prompt: Optional[str] = None
suffix: Optional[str] = None
images: Optional[list[str]] = None
format: Optional[Union[dict, str]] = None
+30 -17
View File
@@ -57,6 +57,7 @@ from open_webui.utils.misc import (
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
log = logging.getLogger(__name__)
@@ -91,6 +92,12 @@ async def send_get_request(url, key=None, user: UserModel = None):
return None
async def get_models_request(url, key=None, user: UserModel = None):
if is_anthropic_url(url):
return await get_anthropic_models(url, key, user=user)
return await send_get_request(f"{url}/models", key, user=user)
def openai_reasoning_model_handler(payload):
"""
Handle reasoning model specific parameters
@@ -365,13 +372,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
request_tasks = []
for idx, url in enumerate(api_base_urls):
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
request_tasks.append(
send_get_request(
f"{url}/models",
api_keys[idx],
user=user,
)
)
request_tasks.append(get_models_request(url, api_keys[idx], user=user))
else:
api_config = api_configs.get(
str(idx),
@@ -384,11 +385,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
if enable:
if len(model_ids) == 0:
request_tasks.append(
send_get_request(
f"{url}/models",
api_keys[idx],
user=user,
)
get_models_request(url, api_keys[idx], user=user)
)
else:
model_list = {
@@ -594,6 +591,10 @@ async def get_models(
"data": api_config.get("model_ids", []) or [],
"object": "list",
}
elif is_anthropic_url(url):
models = await get_anthropic_models(url, key, user=user)
if models is None:
raise Exception("Failed to connect to Anthropic API")
else:
async with session.get(
f"{url}/models",
@@ -602,7 +603,6 @@ async def get_models(
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
try:
res = await r.json()
@@ -614,9 +614,7 @@ async def get_models(
response_data = await r.json()
# Check if we're calling OpenAI API based on the URL
if "api.openai.com" in url:
# Filter models according to the specified conditions
response_data["data"] = [
model
for model in response_data.get("data", [])
@@ -707,6 +705,15 @@ async def verify_connection(
)
return response_data
elif is_anthropic_url(url):
result = await get_anthropic_models(url, key)
if result is None:
raise HTTPException(
status_code=500, detail="Failed to connect to Anthropic API"
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
else:
async with session.get(
f"{url}/models",
@@ -1181,7 +1188,10 @@ async def embeddings(request: Request, form_data: dict, user):
request, url, key, api_config, user=user
)
try:
session = aiohttp.ClientSession(trust_env=True)
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
r = await session.request(
method="POST",
url=f"{url}/embeddings",
@@ -1408,7 +1418,10 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
else:
request_url = f"{url}/{path}"
session = aiohttp.ClientSession(trust_env=True)
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
r = await session.request(
method=request.method,
url=request_url,
+51 -20
View File
@@ -9,7 +9,7 @@ from open_webui.models.prompts import (
PromptModel,
Prompts,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
PromptHistories,
@@ -18,7 +18,7 @@ from open_webui.models.prompt_history import (
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@@ -473,30 +473,61 @@ async def update_prompt_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_prompts",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_prompts",
)
AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db)
return Prompts.get_prompt_by_id(prompt_id, db=db)
############################
# TogglePromptActiveById
############################
@router.post("/id/{prompt_id}/toggle", response_model=Optional[PromptModel])
async def toggle_prompt_active(
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.toggle_prompt_active(prompt.id, db=db)
if result:
return result
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
# DeletePromptById
############################
+112 -27
View File
@@ -37,6 +37,7 @@ from langchain_text_splitters import (
from langchain_core.documents import Document
from open_webui.models.files import FileModel, FileUpdateForm, Files
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage
from open_webui.internal.db import get_session, get_db
@@ -77,6 +78,7 @@ from open_webui.retrieval.web.sougou import search_sougou
from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.external import search_external
from open_webui.retrieval.web.yandex import search_yandex
from open_webui.retrieval.web.ydc import search_youcom
from open_webui.retrieval.utils import (
get_content_from_url,
@@ -270,6 +272,7 @@ async def get_status(request: Request):
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
}
@@ -281,6 +284,7 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
"RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
"openai_config": {
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
"key": request.app.state.config.RAG_OPENAI_API_KEY,
@@ -321,6 +325,7 @@ class EmbeddingModelUpdateForm(BaseModel):
RAG_EMBEDDING_MODEL: str
RAG_EMBEDDING_BATCH_SIZE: Optional[int] = 1
ENABLE_ASYNC_EMBEDDING: Optional[bool] = True
RAG_EMBEDDING_CONCURRENT_REQUESTS: Optional[int] = 0
def unload_embedding_model(request: Request):
@@ -355,6 +360,9 @@ async def update_embedding_config(
request.app.state.config.ENABLE_ASYNC_EMBEDDING = (
form_data.ENABLE_ASYNC_EMBEDDING
)
request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = (
form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS
)
if request.app.state.config.RAG_EMBEDDING_ENGINE in [
"ollama",
@@ -422,6 +430,7 @@ async def update_embedding_config(
else None
),
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
)
return {
@@ -430,6 +439,7 @@ async def update_embedding_config(
"RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
"openai_config": {
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
"key": request.app.state.config.RAG_OPENAI_API_KEY,
@@ -583,6 +593,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
"YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY,
},
}
@@ -649,6 +660,7 @@ class WebConfig(BaseModel):
YANDEX_WEB_SEARCH_URL: Optional[str] = None
YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None
YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None
YOUCOM_API_KEY: Optional[str] = None
class ConfigForm(BaseModel):
@@ -717,10 +729,10 @@ class ConfigForm(BaseModel):
CHUNK_OVERLAP: Optional[int] = None
# File upload settings
FILE_MAX_SIZE: Optional[int] = None
FILE_MAX_COUNT: Optional[int] = None
FILE_IMAGE_COMPRESSION_WIDTH: Optional[int] = None
FILE_IMAGE_COMPRESSION_HEIGHT: Optional[int] = None
FILE_MAX_SIZE: Optional[Union[int, str]] = None
FILE_MAX_COUNT: Optional[Union[int, str]] = None
FILE_IMAGE_COMPRESSION_WIDTH: Optional[Union[int, str]] = None
FILE_IMAGE_COMPRESSION_HEIGHT: Optional[Union[int, str]] = None
ALLOWED_FILE_EXTENSIONS: Optional[List[str]] = None
# Integration settings
@@ -1043,26 +1055,29 @@ async def update_rag_config(
)
# File upload settings
request.app.state.config.FILE_MAX_SIZE = (
form_data.FILE_MAX_SIZE
if form_data.FILE_MAX_SIZE is not None
else request.app.state.config.FILE_MAX_SIZE
)
request.app.state.config.FILE_MAX_COUNT = (
form_data.FILE_MAX_COUNT
if form_data.FILE_MAX_COUNT is not None
else request.app.state.config.FILE_MAX_COUNT
)
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
form_data.FILE_IMAGE_COMPRESSION_WIDTH
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None
else request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH
)
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
form_data.FILE_IMAGE_COMPRESSION_HEIGHT
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None
else request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT
)
# Empty string means "clear to None" (unlimited/no compression),
# None means "don't change", int means "set to this value"
if form_data.FILE_MAX_SIZE is not None:
request.app.state.config.FILE_MAX_SIZE = (
None if form_data.FILE_MAX_SIZE == "" else form_data.FILE_MAX_SIZE
)
if form_data.FILE_MAX_COUNT is not None:
request.app.state.config.FILE_MAX_COUNT = (
None if form_data.FILE_MAX_COUNT == "" else form_data.FILE_MAX_COUNT
)
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None:
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
None
if form_data.FILE_IMAGE_COMPRESSION_WIDTH == ""
else form_data.FILE_IMAGE_COMPRESSION_WIDTH
)
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None:
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
None
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT == ""
else form_data.FILE_IMAGE_COMPRESSION_HEIGHT
)
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
form_data.ALLOWED_FILE_EXTENSIONS
if form_data.ALLOWED_FILE_EXTENSIONS is not None
@@ -1205,6 +1220,7 @@ async def update_rag_config(
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = (
form_data.web.YANDEX_WEB_SEARCH_CONFIG
)
request.app.state.config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY
return {
"status": True,
@@ -1332,6 +1348,7 @@ async def update_rag_config(
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
"YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY,
},
}
@@ -1589,6 +1606,7 @@ def save_docs_to_vector_db(
else None
),
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
)
# Run async embedding in sync context using the main event loop
@@ -1746,6 +1764,7 @@ def process_file(
DOCLING_API_KEY=request.app.state.config.DOCLING_API_KEY,
DOCLING_PARAMS=request.app.state.config.DOCLING_PARAMS,
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE=request.app.state.config.PDF_LOADER_MODE,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
DOCUMENT_INTELLIGENCE_MODEL=request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL,
@@ -1933,6 +1952,9 @@ async def process_web(
request: Request,
form_data: ProcessUrlForm,
process: bool = Query(True, description="Whether to process and save the content"),
overwrite: bool = Query(
True, description="Whether to overwrite existing collection"
),
user=Depends(get_verified_user),
):
try:
@@ -1952,7 +1974,8 @@ async def process_web(
request,
docs,
collection_name,
overwrite=True,
overwrite=overwrite,
add=(not overwrite),
user=user,
)
else:
@@ -2288,6 +2311,13 @@ def search_web(
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
user=user,
)
elif engine == "youcom":
return search_youcom(
request.app.state.config.YOUCOM_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No search engine API key found in environment variables")
@@ -2327,7 +2357,7 @@ async def process_web_search(
# Limited concurrency with semaphore
semaphore = asyncio.Semaphore(concurrent_limit)
async def search_with_limit(query):
async def search_query_with_semaphore(query):
async with semaphore:
return await run_in_threadpool(
search_web,
@@ -2337,7 +2367,9 @@ async def process_web_search(
user,
)
search_tasks = [search_with_limit(query) for query in form_data.queries]
search_tasks = [
search_query_with_semaphore(query) for query in form_data.queries
]
else:
# Unlimited parallel execution (previous behavior)
search_tasks = [
@@ -2462,6 +2494,34 @@ async def process_web_search(
)
def _validate_collection_access(collection_names: list[str], user) -> None:
"""
Prevent users from querying collections they don't own.
Enforces ownership on user-memory-* and file-* collections.
Admins bypass this check.
"""
if user.role == "admin":
return
for name in collection_names:
if name.startswith("user-memory-") and name != f"user-memory-{user.id}":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
elif name.startswith("file-"):
file_id = name[len("file-") :]
if not has_access_to_file(
file_id=file_id,
access_type="read",
user=user,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
class QueryDocForm(BaseModel):
collection_name: str
query: str
@@ -2477,6 +2537,8 @@ async def query_doc_handler(
form_data: QueryDocForm,
user=Depends(get_verified_user),
):
_validate_collection_access([form_data.collection_name], user)
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
form_data.hybrid is None or form_data.hybrid
@@ -2551,6 +2613,8 @@ async def query_collection_handler(
form_data: QueryCollectionsForm,
user=Depends(get_verified_user),
):
_validate_collection_access(form_data.collection_names, user)
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
form_data.hybrid is None or form_data.hybrid
@@ -2729,6 +2793,27 @@ async def process_files_batch(
for file in form_data.files:
try:
# Ownership check: verify the requesting user owns the file or is an admin
db_file = Files.get_file_by_id(file.id)
if not db_file:
file_errors.append(
BatchProcessFilesResult(
file_id=file.id,
status="failed",
error="File not found",
)
)
continue
if db_file.user_id != user.id and user.role != "admin":
file_errors.append(
BatchProcessFilesResult(
file_id=file.id,
status="failed",
error="Permission denied: not file owner",
)
)
continue
text_content = file.data.get("content", "")
docs: List[Document] = [
Document(
+13 -4
View File
@@ -523,13 +523,17 @@ async def get_schemas():
@router.get("/Users", response_model=SCIMListResponse)
async def get_users(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
startIndex: int = Query(1),
count: int = Query(20),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""List SCIM Users"""
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
startIndex = max(1, startIndex)
count = max(0, min(100, count))
skip = startIndex - 1
limit = count
@@ -794,13 +798,18 @@ async def delete_user(
@router.get("/Groups", response_model=SCIMListResponse)
async def get_groups(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
startIndex: int = Query(1),
count: int = Query(20),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""List SCIM Groups"""
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
startIndex = max(1, startIndex)
count = max(0, min(100, count))
# Get all groups
groups_list = Groups.get_all_groups(db=db)
+9 -20
View File
@@ -17,9 +17,9 @@ from open_webui.models.skills import (
SkillAccessListResponse,
Skills,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
@@ -341,24 +341,13 @@ async def update_skill_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_skills",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_skills",
)
AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db)
+304
View File
@@ -0,0 +1,304 @@
"""Reverse proxy for admin-configured terminal servers.
Routes:
GET / — list terminals the user has access to
* /{server_id}/{path:path} — proxy request to terminal server
"""
import logging
import aiohttp
from fastapi import APIRouter, Depends, Request, Response, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.background import BackgroundTask
from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_connection_access
from open_webui.models.groups import Groups
from open_webui.models.users import Users
log = logging.getLogger(__name__)
router = APIRouter()
STREAMING_CONTENT_TYPES = ("application/octet-stream", "image/", "application/pdf")
STRIPPED_RESPONSE_HEADERS = frozenset(
("transfer-encoding", "connection", "content-encoding", "content-length")
)
@router.get("/")
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
"""Return terminal servers the authenticated user has access to."""
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
return [
{
"id": connection.get("id", ""),
"url": connection.get("url", ""),
"name": connection.get("name", ""),
}
for connection in connections
if connection.get("enabled", True)
and has_connection_access(user, connection, user_group_ids)
]
PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
@router.api_route("/{server_id}/{path:path}", methods=PROXY_METHODS)
async def proxy_terminal(
server_id: str,
path: str,
request: Request,
user=Depends(get_verified_user),
):
"""Proxy a request to the admin terminal server identified by *server_id*."""
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get("id") == server_id), None)
if connection is None:
return JSONResponse(
{"error": f"Terminal server '{server_id}' not found"}, status_code=404
)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
if not has_connection_access(user, connection, user_group_ids):
return JSONResponse({"error": "Access denied"}, status_code=403)
base_url = (connection.get("url") or "").rstrip("/")
if not base_url:
return JSONResponse(
{"error": "Terminal server URL not configured"}, status_code=503
)
target_url = f"{base_url}/{path}"
if request.query_params:
target_url += f"?{request.query_params}"
headers = {"X-User-Id": user.id}
cookies = {}
auth_type = connection.get("auth_type", "bearer")
if auth_type == "bearer":
headers["Authorization"] = f"Bearer {connection.get('key', '')}"
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = f"Bearer {request.state.token.credentials}"
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = request.headers.get("x-oauth-access-token", "")
if oauth_token:
headers["Authorization"] = f"Bearer {oauth_token}"
# auth_type == "none": no Authorization header
content_type = request.headers.get("content-type")
if content_type:
headers["Content-Type"] = content_type
body = await request.body()
session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=300, connect=10),
trust_env=True,
)
try:
upstream_response = await session.request(
method=request.method,
url=target_url,
headers=headers,
cookies=cookies,
data=body or None,
)
upstream_content_type = upstream_response.headers.get("content-type", "")
filtered_headers = {
key: value
for key, value in upstream_response.headers.items()
if key.lower() not in STRIPPED_RESPONSE_HEADERS
}
# Stream binary responses directly
if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES):
async def cleanup():
await upstream_response.release()
await session.close()
return StreamingResponse(
content=upstream_response.content.iter_any(),
status_code=upstream_response.status,
headers=filtered_headers,
background=BackgroundTask(cleanup),
)
# Buffer text/JSON responses
response_body = await upstream_response.read()
status_code = upstream_response.status
await upstream_response.release()
await session.close()
return Response(
content=response_body, status_code=status_code, headers=filtered_headers
)
except Exception as error:
await session.close()
log.exception("Terminal proxy error: %s", error)
return JSONResponse(
{"error": f"Terminal proxy error: {error}"}, status_code=502
)
# ---------------------------------------------------------------------------
# WebSocket proxy for interactive terminal sessions
# ---------------------------------------------------------------------------
async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
"""Authenticate a WebSocket via first-message auth and resolve the terminal server.
The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first
message after connecting.
Returns ``(user, connection)`` on success, or ``None`` after closing *ws*
with an appropriate error code.
"""
import asyncio
import json
from open_webui.utils.auth import decode_token
# First-message authentication
try:
raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0)
payload = json.loads(raw)
if payload.get("type") != "auth":
await ws.close(code=4001, reason="Expected auth message")
return None
token = payload.get("token", "")
data = decode_token(token)
if data is None or "id" not in data:
await ws.close(code=4001, reason="Invalid token")
return None
user = Users.get_user_by_id(data["id"])
if user is None:
await ws.close(code=4001, reason="User not found")
return None
except (asyncio.TimeoutError, json.JSONDecodeError):
await ws.close(code=4001, reason="Auth timeout or invalid payload")
return None
except Exception:
await ws.close(code=4001, reason="Invalid token")
return None
# Resolve terminal server
connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get("id") == server_id), None)
if connection is None:
await ws.close(code=4004, reason="Terminal server not found")
return None
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
if not has_connection_access(user, connection, user_group_ids):
await ws.close(code=4003, reason="Access denied")
return None
return user, connection
@router.websocket("/{server_id}/api/terminals/{session_id}")
async def ws_terminal(
ws: WebSocket,
server_id: str,
session_id: str,
):
"""Proxy an interactive WebSocket terminal session to a terminal server.
Uses first-message auth: the client sends ``{"type": "auth", "token": "<jwt>"}``
as its first message. The proxy validates the JWT, then connects to the
upstream terminal server and authenticates with the server's API key.
"""
await ws.accept()
result = await _resolve_authenticated_connection(ws, server_id)
if result is None:
return
user, connection = result
base_url = (connection.get("url") or "").rstrip("/")
if not base_url:
await ws.close(code=4003, reason="Terminal server URL not configured")
return
# Build upstream WebSocket URL (no token in URL)
ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://")
auth_type = connection.get("auth_type", "bearer")
upstream_params = {}
# For orchestrator-backed servers, pass user_id
upstream_params["user_id"] = user.id
import urllib.parse
upstream_url = f"{ws_base}/api/terminals/{session_id}"
if upstream_params:
upstream_url += f"?{urllib.parse.urlencode(upstream_params)}"
session = aiohttp.ClientSession()
try:
async with session.ws_connect(upstream_url) as upstream:
import asyncio
import json as _json
# First-message auth to upstream terminal server
auth_type = connection.get("auth_type", "bearer")
if auth_type == "bearer":
key = connection.get("key", "")
await upstream.send_str(_json.dumps({"type": "auth", "token": key}))
async def _client_to_upstream():
"""Forward client → upstream."""
try:
while True:
msg = await ws.receive()
if msg["type"] == "websocket.disconnect":
break
elif "bytes" in msg and msg["bytes"]:
await upstream.send_bytes(msg["bytes"])
elif "text" in msg and msg["text"]:
await upstream.send_str(msg["text"])
except Exception:
pass
async def _upstream_to_client():
"""Forward upstream → client."""
try:
async for msg in upstream:
if msg.type == aiohttp.WSMsgType.BINARY:
await ws.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_text(msg.data)
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR,
):
break
except Exception:
pass
await asyncio.gather(
_client_to_upstream(),
_upstream_to_client(),
return_exceptions=True,
)
except Exception as e:
log.exception("Terminal WebSocket proxy error: %s", e)
finally:
await session.close()
try:
await ws.close()
except Exception:
pass
+48 -40
View File
@@ -21,7 +21,7 @@ from open_webui.models.tools import (
ToolAccessResponse,
Tools,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
@@ -30,7 +30,7 @@ from open_webui.utils.plugin import (
)
from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.utils.tools import get_tool_servers
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
@@ -64,13 +64,19 @@ async def get_tools(
tools = []
# Local Tools
for tool in Tools.get_tools(db=db):
tool_module = get_tool_module(request, tool.id)
for tool in Tools.get_tools(defer_content=True, db=db):
tool_module = (
request.app.state.TOOLS.get(tool.id)
if hasattr(request.app.state, "TOOLS")
else None
)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": hasattr(tool_module, "UserValves"),
"has_user_valves": (
hasattr(tool_module, "UserValves") if tool_module else False
),
}
)
)
@@ -196,27 +202,40 @@ async def get_tool_list(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
tools = Tools.get_tools(db=db)
tools = Tools.get_tools(defer_content=True, db=db)
else:
tools = Tools.get_tools_by_user_id(user.id, "read", db=db)
tools = Tools.get_tools_by_user_id(user.id, "read", defer_content=True, db=db)
return [
ToolAccessResponse(
**tool.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tool.id,
permission="write",
db=db,
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
result = []
for tool in tools:
has_write = (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or any(
g.permission == "write"
and (
(
g.principal_type == "user"
and (g.principal_id == user.id or g.principal_id == "*")
)
or (
g.principal_type == "group" and g.principal_id in user_group_ids
)
)
),
for g in tool.access_grants
)
)
for tool in tools
]
result.append(
ToolAccessResponse(
**tool.model_dump(),
write_access=has_write,
)
)
return result
############################
@@ -557,24 +576,13 @@ async def update_tool_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_tools",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_tools",
)
AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db)
+9
View File
@@ -196,12 +196,17 @@ class SharingPermissions(BaseModel):
public_notes: bool = True
class AccessGrantsPermissions(BaseModel):
allow_users: bool = True
class ChatPermissions(BaseModel):
controls: bool = True
valves: bool = True
system_prompt: bool = True
params: bool = True
file_upload: bool = True
web_upload: bool = True
delete: bool = True
delete_message: bool = True
continue_response: bool = True
@@ -238,6 +243,7 @@ class SettingsPermissions(BaseModel):
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
sharing: SharingPermissions
access_grants: AccessGrantsPermissions
chat: ChatPermissions
features: FeaturesPermissions
settings: SettingsPermissions
@@ -252,6 +258,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_
"sharing": SharingPermissions(
**request.app.state.config.USER_PERMISSIONS.get("sharing", {})
),
"access_grants": AccessGrantsPermissions(
**request.app.state.config.USER_PERMISSIONS.get("access_grants", {})
),
"chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
),
+39 -20
View File
@@ -37,6 +37,7 @@ from open_webui.env import (
WEBSOCKET_SERVER_PING_INTERVAL,
WEBSOCKET_SERVER_LOGGING,
WEBSOCKET_SERVER_ENGINEIO_LOGGING,
WEBSOCKET_EVENT_CALLER_TIMEOUT,
)
from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
@@ -511,6 +512,8 @@ async def channel_events(sid, data):
async def ydoc_document_join(sid, data):
"""Handle user joining a document"""
user = SESSION_POOL.get(sid)
if not user:
return
try:
document_id = data["document_id"]
@@ -683,11 +686,13 @@ async def yjs_document_update(sid, data):
skip_sid=sid,
)
user = SESSION_POOL.get(sid)
if not user:
return
async def debounced_save():
await asyncio.sleep(0.5)
await document_save_handler(
document_id, data.get("data", {}), SESSION_POOL.get(sid)
)
await document_save_handler(document_id, data.get("data", {}), user)
if data.get("data"):
await create_task(REDIS, debounced_save(), document_id)
@@ -786,21 +791,26 @@ def get_event_emitter(request_info, update_db=True):
},
room=f"user:{user_id}",
)
if (
update_db
and message_id
and not request_info.get("chat_id", "").startswith("local:")
):
if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id(
event_type = event_data.get("type")
if event_type == "status":
await asyncio.to_thread(
Chats.add_message_status_to_chat_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
event_data.get("data", {}),
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
elif event_type == "message":
message = await asyncio.to_thread(
Chats.get_message_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
)
@@ -809,7 +819,8 @@ def get_event_emitter(request_info, update_db=True):
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
await asyncio.to_thread(
Chats.upsert_message_to_chat_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
{
@@ -817,10 +828,11 @@ def get_event_emitter(request_info, update_db=True):
},
)
if "type" in event_data and event_data["type"] == "replace":
elif event_type == "replace":
content = event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
await asyncio.to_thread(
Chats.upsert_message_to_chat_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
{
@@ -828,8 +840,9 @@ def get_event_emitter(request_info, update_db=True):
},
)
if "type" in event_data and event_data["type"] == "embeds":
message = Chats.get_message_by_id_and_message_id(
elif event_type == "embeds":
message = await asyncio.to_thread(
Chats.get_message_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
)
@@ -837,7 +850,8 @@ def get_event_emitter(request_info, update_db=True):
embeds = event_data.get("data", {}).get("embeds", [])
embeds.extend(message.get("embeds", []))
Chats.upsert_message_to_chat_by_id_and_message_id(
await asyncio.to_thread(
Chats.upsert_message_to_chat_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
{
@@ -845,8 +859,9 @@ def get_event_emitter(request_info, update_db=True):
},
)
if "type" in event_data and event_data["type"] == "files":
message = Chats.get_message_by_id_and_message_id(
elif event_type == "files":
message = await asyncio.to_thread(
Chats.get_message_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
)
@@ -854,7 +869,8 @@ def get_event_emitter(request_info, update_db=True):
files = event_data.get("data", {}).get("files", [])
files.extend(message.get("files", []))
Chats.upsert_message_to_chat_by_id_and_message_id(
await asyncio.to_thread(
Chats.upsert_message_to_chat_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
{
@@ -862,10 +878,11 @@ def get_event_emitter(request_info, update_db=True):
},
)
if event_data.get("type") in ["source", "citation"]:
elif event_type in ("source", "citation"):
data = event_data.get("data", {})
if data.get("type") == None:
message = Chats.get_message_by_id_and_message_id(
if data.get("type") is None:
message = await asyncio.to_thread(
Chats.get_message_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
)
@@ -873,7 +890,8 @@ def get_event_emitter(request_info, update_db=True):
sources = message.get("sources", [])
sources.append(data)
Chats.upsert_message_to_chat_by_id_and_message_id(
await asyncio.to_thread(
Chats.upsert_message_to_chat_by_id_and_message_id,
request_info["chat_id"],
request_info["message_id"],
{
@@ -901,6 +919,7 @@ def get_event_call(request_info):
"data": event_data,
},
to=request_info["session_id"],
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
)
return response
@@ -1,91 +0,0 @@
from test.util.abstract_integration_test import AbstractPostgresTest
from test.util.mock_user import mock_webui_user
class TestPrompts(AbstractPostgresTest):
BASE_PATH = "/api/v1/prompts"
def test_prompts(self):
# Get all prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
assert len(response.json()) == 0
# Create a two new prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url("/create"),
json={
"command": "/my-command",
"title": "Hello World",
"content": "description",
},
)
assert response.status_code == 200
with mock_webui_user(id="3"):
response = self.fast_api_client.post(
self.create_url("/create"),
json={
"command": "/my-command2",
"title": "Hello World 2",
"content": "description 2",
},
)
assert response.status_code == 200
# Get all prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
assert len(response.json()) == 2
# Get prompt by command
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/command/my-command"))
assert response.status_code == 200
data = response.json()
assert data["command"] == "/my-command"
assert data["title"] == "Hello World"
assert data["content"] == "description"
assert data["user_id"] == "2"
# Update prompt
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url("/command/my-command2/update"),
json={
"command": "irrelevant for request",
"title": "Hello World Updated",
"content": "description Updated",
},
)
assert response.status_code == 200
data = response.json()
assert data["command"] == "/my-command2"
assert data["title"] == "Hello World Updated"
assert data["content"] == "description Updated"
assert data["user_id"] == "3"
# Get prompt by command
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/command/my-command2"))
assert response.status_code == 200
data = response.json()
assert data["command"] == "/my-command2"
assert data["title"] == "Hello World Updated"
assert data["content"] == "description Updated"
assert data["user_id"] == "3"
# Delete prompt
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(
self.create_url("/command/my-command/delete")
)
assert response.status_code == 200
# Get all prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
assert len(response.json()) == 1
+87 -6
View File
@@ -36,6 +36,8 @@ from open_webui.models.chats import Chats
from open_webui.models.channels import Channels, ChannelMember, Channel
from open_webui.models.messages import Messages, Message
from open_webui.models.groups import Groups
from open_webui.models.memories import Memories
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.utils.sanitize import sanitize_code
log = logging.getLogger(__name__)
@@ -153,8 +155,7 @@ async def search_web(
) -> str:
"""
Search the public web for information. Best for current events, external references,
or topics not covered in internal documents. If knowledge base tools are available,
consider checking those first for internal information.
or topics not covered in internal documents.
:param query: The search query to look up
:param count: Number of results to return (default: 5)
@@ -248,11 +249,13 @@ async def generate_image(
# Persist files to DB if chat context is available
if __chat_id__ and __message_id__ and images:
image_files = Chats.add_message_files_by_id_and_message_id(
db_files = Chats.add_message_files_by_id_and_message_id(
__chat_id__,
__message_id__,
image_files,
)
if db_files is not None:
image_files = db_files
# Emit the images to the UI if event emitter is available
if __event_emitter__ and image_files:
@@ -313,11 +316,13 @@ async def edit_image(
# Persist files to DB if chat context is available
if __chat_id__ and __message_id__ and images:
image_files = Chats.add_message_files_by_id_and_message_id(
db_files = Chats.add_message_files_by_id_and_message_id(
__chat_id__,
__message_id__,
image_files,
)
if db_files is not None:
image_files = db_files
# Emit the images to the UI if event emitter is available
if __event_emitter__ and image_files:
@@ -424,6 +429,9 @@ async def execute_code(
"session_id": (
__metadata__.get("session_id") if __metadata__ else None
),
"files": (
__metadata__.get("files", []) if __metadata__ else []
),
},
}
)
@@ -634,6 +642,79 @@ async def replace_memory_content(
return json.dumps({"error": str(e)})
async def delete_memory(
memory_id: str,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Delete a memory by its ID.
:param memory_id: The ID of the memory to delete
:return: Confirmation that the memory was deleted
"""
if __request__ is None:
return json.dumps({"error": "Request context not available"})
try:
user = UserModel(**__user__) if __user__ else None
result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id)
if result:
VECTOR_DB_CLIENT.delete(
collection_name=f"user-memory-{user.id}", ids=[memory_id]
)
return json.dumps(
{"status": "success", "message": f"Memory {memory_id} deleted"},
ensure_ascii=False,
)
else:
return json.dumps({"error": "Memory not found or access denied"})
except Exception as e:
log.exception(f"delete_memory error: {e}")
return json.dumps({"error": str(e)})
async def list_memories(
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
List all stored memories for the user.
:return: JSON list of all memories with id, content, and dates
"""
if __request__ is None:
return json.dumps({"error": "Request context not available"})
try:
user = UserModel(**__user__) if __user__ else None
memories = Memories.get_memories_by_user_id(user.id)
if memories:
result = [
{
"id": m.id,
"content": m.content,
"created_at": time.strftime(
"%Y-%m-%d %H:%M", time.localtime(m.created_at)
),
"updated_at": time.strftime(
"%Y-%m-%d %H:%M", time.localtime(m.updated_at)
),
}
for m in memories
]
return json.dumps(result, ensure_ascii=False)
else:
return json.dumps([])
except Exception as e:
log.exception(f"list_memories error: {e}")
return json.dumps({"error": str(e)})
# =============================================================================
# NOTES TOOLS
# =============================================================================
@@ -1552,7 +1633,7 @@ async def view_file(
try:
from open_webui.models.files import Files
from open_webui.routers.files import has_access_to_file
from open_webui.utils.access_control.files import has_access_to_file
user_id = __user__.get("id")
user_role = __user__.get("role", "user")
@@ -1756,7 +1837,7 @@ async def query_knowledge_files(
elif item_type == "file":
# Individual file - use file-{id} as collection name
file = Files.get_file_by_id(item_id)
if file and (user_role == "admin" or file.user_id == user_id):
if file:
collection_names.append(f"file-{item_id}")
elif item_type == "note":
@@ -153,6 +153,31 @@ def has_access(
return False
def has_connection_access(
user: UserModel,
connection: dict,
user_group_ids: Optional[Set[str]] = None,
) -> bool:
"""
Check if a user can access a server connection (tool server, terminal, etc.)
based on ``config.access_grants`` within the connection dict.
- Admin with BYPASS_ADMIN_ACCESS_CONTROL → always allowed
- Missing, None, or empty access_grants → private, admin-only
- access_grants has entries → delegates to ``has_access``
"""
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return True
if user_group_ids is None:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
access_grants = (connection.get("config") or {}).get("access_grants", [])
return has_access(user.id, "read", access_grants, user_group_ids)
def migrate_access_control(
data: dict, ac_key: str = "access_control", grants_key: str = "access_grants"
) -> None:
@@ -194,3 +219,63 @@ def migrate_access_control(
data[grants_key] = grants
data.pop(ac_key, None)
from open_webui.models.access_grants import (
has_public_read_access_grant,
has_user_access_grant,
strip_user_access_grants,
)
def filter_allowed_access_grants(
default_permissions: Dict[str, Any],
user_id: str,
user_role: str,
access_grants: list,
public_permission_key: str,
db: Optional[Any] = None,
) -> list:
"""
Checks if the user has the required permissions to grant access to a resource.
Returns the filtered list of access grants if permissions are missing.
"""
if user_role == "admin" or not access_grants:
return access_grants
# Check if user can share publicly
if has_public_read_access_grant(access_grants) and not has_permission(
user_id,
public_permission_key,
default_permissions,
db=db,
):
access_grants = [
grant
for grant in access_grants
if not (
(
grant.get("principal_type")
if isinstance(grant, dict)
else getattr(grant, "principal_type", None)
)
== "user"
and (
grant.get("principal_id")
if isinstance(grant, dict)
else getattr(grant, "principal_id", None)
)
== "*"
)
]
# Strip individual user sharing if user lacks permission
if has_user_access_grant(access_grants) and not has_permission(
user_id,
"access_grants.allow_users",
default_permissions,
db=db,
):
access_grants = strip_user_access_grants(access_grants)
return access_grants
@@ -0,0 +1,88 @@
import logging
from typing import Optional, Any
from open_webui.models.users import UserModel
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
log = logging.getLogger(__name__)
def has_access_to_file(
file_id: Optional[str],
access_type: str,
user: UserModel,
db: Optional[Any] = None,
) -> bool:
"""
Check if a user has the specified access to a file through any of:
- Knowledge bases (ownership or access grants)
- Shared workspace models that attach the file directly
- Channels the user is a member of
- Shared chats
NOTE: This does NOT check direct file ownership — callers should check
file.user_id == user.id separately before calling this.
"""
file = Files.get_file_by_id(file_id, db=db)
log.debug(f"Checking if user has {access_type} access to file")
if not file:
return False
# Direct ownership
if file.user_id == user.id:
return True
# Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
):
return True
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type, db=db
)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
return True
# Check if the file is associated with any channels the user has access to
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
if access_type == "read" and channels:
return True
# Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats
chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
if chats:
return True
# Check if the file is directly attached to a shared workspace model
for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db):
knowledge_items = getattr(model.meta, "knowledge", None) or []
for item in knowledge_items:
if (
isinstance(item, dict)
and item.get("type") == "file"
and item.get("id") == file.id
):
return True
return False
+534
View File
@@ -0,0 +1,534 @@
import json
import logging
import aiohttp
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
from open_webui.models.users import UserModel
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
def is_anthropic_url(url: str) -> bool:
"""Check if the URL is an Anthropic API endpoint."""
return "api.anthropic.com" in url
async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> dict:
"""
Fetch models from Anthropic's /v1/models endpoint with pagination.
Normalizes the response to OpenAI format.
"""
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
all_models = []
after_id = None
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
headers = {
"x-api-key": key,
"anthropic-version": "2023-06-01",
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
while True:
params = {"limit": 1000}
if after_id:
params["after_id"] = after_id
async with session.get(
f"{url}/models",
headers=headers,
params=params,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
if response.status != 200:
error_detail = f"HTTP Error: {response.status}"
try:
res = await response.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
except Exception:
pass
return {"object": "list", "data": [], "error": error_detail}
data = await response.json()
for model in data.get("data", []):
all_models.append(
{
"id": model.get("id"),
"object": "model",
"created": 0,
"owned_by": "anthropic",
"name": model.get("display_name", model.get("id")),
}
)
if not data.get("has_more", False):
break
after_id = data.get("last_id")
except Exception as e:
log.error(f"Anthropic connection error: {e}")
return None
return {"object": "list", "data": all_models}
##############################
#
# Anthropic Messages API Conversion Utilities
#
##############################
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
"""
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
Anthropic format:
{model, messages: [{role, content}], system, max_tokens, ...}
OpenAI format:
{model, messages: [{role, content}], max_tokens, ...}
"""
openai_payload = {}
# Model
openai_payload["model"] = anthropic_payload.get("model", "")
# Build messages list
messages = []
# System prompt (Anthropic has it as top-level, OpenAI as a system message)
system = anthropic_payload.get("system")
if system:
if isinstance(system, str):
messages.append({"role": "system", "content": system})
elif isinstance(system, list):
# Anthropic supports system as list of content blocks
text_parts = []
for block in system:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif isinstance(block, str):
text_parts.append(block)
messages.append({"role": "system", "content": "\n".join(text_parts)})
# Convert messages
for msg in anthropic_payload.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content")
if isinstance(content, str):
messages.append({"role": role, "content": content})
elif isinstance(content, list):
# Convert Anthropic content blocks to OpenAI format
openai_content = []
tool_calls = []
for block in content:
block_type = block.get("type", "text")
if block_type == "text":
openai_content.append(
{
"type": "text",
"text": block.get("text", ""),
}
)
elif block_type == "image":
source = block.get("source", {})
if source.get("type") == "base64":
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
openai_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{data}",
},
}
)
elif source.get("type") == "url":
openai_content.append(
{
"type": "image_url",
"image_url": {"url": source.get("url", "")},
}
)
elif block_type == "tool_use":
tool_calls.append(
{
"id": block.get("id", ""),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": (
json.dumps(block.get("input", {}))
if isinstance(block.get("input"), dict)
else str(block.get("input", "{}"))
),
},
}
)
elif block_type == "tool_result":
# Tool results become separate tool messages in OpenAI format
tool_content = block.get("content", "")
if isinstance(tool_content, list):
tool_text_parts = []
for tc in tool_content:
if isinstance(tc, dict) and tc.get("type") == "text":
tool_text_parts.append(tc.get("text", ""))
tool_content = "\n".join(tool_text_parts)
# Propagate error status if present
if block.get("is_error"):
tool_content = f"Error: {tool_content}"
messages.append(
{
"role": "tool",
"tool_call_id": block.get("tool_use_id", ""),
"content": tool_content,
}
)
# Build the message
if tool_calls:
# Assistant message with tool calls
msg_dict = {"role": role}
if openai_content:
# If there's only text, flatten it
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
msg_dict["content"] = openai_content[0]["text"]
else:
msg_dict["content"] = openai_content
else:
msg_dict["content"] = ""
msg_dict["tool_calls"] = tool_calls
messages.append(msg_dict)
elif openai_content:
# If there's only a single text block, flatten it to a string
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
messages.append(
{"role": role, "content": openai_content[0]["text"]}
)
else:
messages.append({"role": role, "content": openai_content})
else:
messages.append({"role": role, "content": str(content) if content else ""})
openai_payload["messages"] = messages
# max_tokens
if "max_tokens" in anthropic_payload:
openai_payload["max_tokens"] = anthropic_payload["max_tokens"]
# Common parameters
for param in ("temperature", "top_p", "stop_sequences", "stream"):
if param in anthropic_payload:
if param == "stop_sequences":
openai_payload["stop"] = anthropic_payload[param]
else:
openai_payload[param] = anthropic_payload[param]
# Tools conversion: Anthropic → OpenAI
if "tools" in anthropic_payload:
openai_tools = []
for tool in anthropic_payload["tools"]:
openai_tools.append(
{
"type": "function",
"function": {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {}),
},
}
)
openai_payload["tools"] = openai_tools
# tool_choice
if "tool_choice" in anthropic_payload:
tc = anthropic_payload["tool_choice"]
if isinstance(tc, dict):
tc_type = tc.get("type", "auto")
if tc_type == "auto":
openai_payload["tool_choice"] = "auto"
elif tc_type == "any":
openai_payload["tool_choice"] = "required"
elif tc_type == "tool":
openai_payload["tool_choice"] = {
"type": "function",
"function": {"name": tc.get("name", "")},
}
return openai_payload
def convert_openai_to_anthropic_response(
openai_response: dict, model: str = ""
) -> dict:
"""
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
"""
import uuid as _uuid
choice = {}
if openai_response.get("choices"):
choice = openai_response["choices"][0]
message = choice.get("message", {})
finish_reason = choice.get("finish_reason", "stop")
# Map finish_reason to stop_reason
stop_reason_map = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"content_filter": "end_turn",
}
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
# Build content blocks
content = []
msg_content = message.get("content")
if msg_content:
content.append({"type": "text", "text": msg_content})
# Tool calls → tool_use blocks
tool_calls = message.get("tool_calls", [])
for tc in tool_calls:
func = tc.get("function", {})
try:
tool_input = json.loads(func.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
tool_input = {}
content.append(
{
"type": "tool_use",
"id": tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}"),
"name": func.get("name", ""),
"input": tool_input,
}
)
# Usage
openai_usage = openai_response.get("usage", {})
usage = {
"input_tokens": openai_usage.get("prompt_tokens", 0),
"output_tokens": openai_usage.get("completion_tokens", 0),
}
return {
"id": openai_response.get("id", f"msg_{_uuid.uuid4().hex[:24]}"),
"type": "message",
"role": "assistant",
"content": content,
"model": model or openai_response.get("model", ""),
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": usage,
}
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ""):
"""
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
OpenAI sends: data: {"choices": [{"delta": {"content": "..."}}]}
Anthropic sends: event: content_block_delta\\ndata: {"type": "content_block_delta", ...}
Handles text content, tool calls, and mixed content with proper
multi-block indexing as required by Anthropic's streaming protocol.
"""
import uuid as _uuid
msg_id = f"msg_{_uuid.uuid4().hex[:24]}"
input_tokens = 0
output_tokens = 0
stop_reason = "end_turn"
# Track content blocks with a running index.
# Each text block or tool_use block gets its own index.
current_block_index = 0
text_block_open = False
# Track tool call state: maps OpenAI tool_call index -> Anthropic block index
# This allows handling multiple concurrent tool calls.
tool_call_blocks = {} # {openai_tc_index: anthropic_block_index}
tool_call_started = {} # {openai_tc_index: bool}
# Emit message_start
message_start = {
"type": "message_start",
"message": {
"id": msg_id,
"type": "message",
"role": "assistant",
"content": [],
"model": model,
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
},
}
yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n".encode()
try:
async for chunk in openai_stream_generator:
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8", errors="ignore")
for line in chunk.strip().split("\n"):
line = line.strip()
if not line or not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
continue
if data_str == "{}":
continue
try:
data = json.loads(data_str)
except (json.JSONDecodeError, TypeError):
continue
choices = data.get("choices", [])
if not choices:
# Check for usage in the final chunk
if data.get("usage"):
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
output_tokens = data["usage"].get(
"completion_tokens", output_tokens
)
continue
delta = choices[0].get("delta", {})
finish_reason = choices[0].get("finish_reason")
# Update usage if present
if data.get("usage"):
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
output_tokens = data["usage"].get(
"completion_tokens", output_tokens
)
# --- Handle text content ---
content = delta.get("content")
if content is not None:
if not text_block_open:
# Start a new text content block
block_start = {
"type": "content_block_start",
"index": current_block_index,
"content_block": {"type": "text", "text": ""},
}
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
text_block_open = True
# Send text delta
block_delta = {
"type": "content_block_delta",
"index": current_block_index,
"delta": {"type": "text_delta", "text": content},
}
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
# --- Handle tool calls ---
tool_calls = delta.get("tool_calls")
if tool_calls:
# Close text block if one is open (text comes before tools)
if text_block_open:
block_stop = {
"type": "content_block_stop",
"index": current_block_index,
}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
text_block_open = False
current_block_index += 1
for tc in tool_calls:
tc_index = tc.get("index", 0)
if tc_index not in tool_call_started:
# First time seeing this tool call — emit content_block_start
tool_call_blocks[tc_index] = current_block_index
tool_call_started[tc_index] = True
# Extract tool call ID and name from the first chunk
tc_id = tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}")
tc_name = tc.get("function", {}).get("name", "")
block_start = {
"type": "content_block_start",
"index": current_block_index,
"content_block": {
"type": "tool_use",
"id": tc_id,
"name": tc_name,
"input": {},
},
}
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
current_block_index += 1
# Emit argument chunks as input_json_delta
args_chunk = tc.get("function", {}).get("arguments", "")
if args_chunk:
block_delta = {
"type": "content_block_delta",
"index": tool_call_blocks[tc_index],
"delta": {
"type": "input_json_delta",
"partial_json": args_chunk,
},
}
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
# --- Handle finish reason ---
if finish_reason is not None:
stop_reason_map = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
}
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
except Exception as e:
log.error(f"Error in Anthropic stream conversion: {e}")
# Close any open text block
if text_block_open:
block_stop = {"type": "content_block_stop", "index": current_block_index}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
# Close any open tool call blocks
for tc_index, block_index in tool_call_blocks.items():
block_stop = {"type": "content_block_stop", "index": block_index}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
# Emit message_delta with stop reason
message_delta = {
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": None,
},
"usage": {"output_tokens": output_tokens},
}
yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode()
# Emit message_stop
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode()
+4
View File
@@ -290,6 +290,10 @@ async def get_current_user(
if token is None and "token" in request.cookies:
token = request.cookies.get("token")
# Fallback to request.state.token (set by middleware, e.g. for x-api-key)
if token is None and hasattr(request.state, "token") and request.state.token:
token = request.state.token.credentials
if token is None:
raise HTTPException(status_code=401, detail="Not authenticated")
+9 -5
View File
@@ -22,10 +22,14 @@ def get_function_module(request, function_id, load_from_db=True):
def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
def get_priority(function_id):
function = Functions.get_function_by_id(function_id)
if function is not None:
valves = Functions.get_function_valves_by_id(function_id)
return valves.get("priority", 0) if valves else 0
try:
function_module = get_function_module(request, function_id)
if function_module and hasattr(function_module, "Valves"):
valves_db = Functions.get_function_valves_by_id(function_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
return getattr(valves, "priority", 0)
except Exception:
pass
return 0
filter_ids = [function.id for function in Functions.get_global_filter_functions()]
@@ -50,7 +54,7 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
]
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
filter_ids.sort(key=get_priority)
filter_ids.sort(key=lambda fid: (get_priority(fid), fid))
return filter_ids
+41 -8
View File
@@ -12,13 +12,15 @@ from open_webui.env import (
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
GLOBAL_LOG_LEVEL,
LOG_FORMAT,
AUDIT_UVICORN_LOGGER_NAMES,
ENABLE_OTEL,
ENABLE_OTEL_LOGS,
_LEVEL_MAP,
)
if TYPE_CHECKING:
from loguru import Record
from loguru import Message, Record
def stdout_format(record: "Record") -> str:
@@ -43,6 +45,29 @@ def stdout_format(record: "Record") -> str:
)
def _json_sink(message: "Message") -> None:
"""Write log records as single-line JSON to stdout.
Used as a Loguru sink when LOG_FORMAT is set to "json".
"""
record = message.record
log_entry = {
"ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
"level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()),
"msg": record["message"],
"caller": f"{record['name']}:{record['function']}:{record['line']}",
}
if record["extra"]:
log_entry["extra"] = record["extra"]
if record["exception"] is not None:
log_entry["error"] = "".join(record["exception"].format_exception()).rstrip()
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n")
sys.stdout.flush()
class InterceptHandler(logging.Handler):
"""
Intercepts log records from Python's standard logging module
@@ -127,14 +152,22 @@ def start_logger():
"""
logger.remove()
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
),
audit_filter = lambda record: (
True if ENABLE_AUDIT_STDOUT else "auditable" not in record["extra"]
)
if LOG_FORMAT == "json":
logger.add(
_json_sink,
level=GLOBAL_LOG_LEVEL,
filter=audit_filter,
)
else:
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=audit_filter,
)
if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
try:
logger.add(
+17 -4
View File
@@ -9,14 +9,27 @@ from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
import httpx
from mcp.shared._httpx_utils import create_mcp_http_client
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
client.verify = False
return client
"""Create an httpx AsyncClient with SSL verification disabled.
Note: verify=False must be passed at construction time because httpx
configures the SSL context during __init__. Setting client.verify = False
after construction does not affect the underlying transport's SSL context.
"""
kwargs = {
"follow_redirects": True,
"verify": False,
}
if timeout is not None:
kwargs["timeout"] = timeout
if headers is not None:
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
return httpx.AsyncClient(**kwargs)
class MCPClient:
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -91,14 +91,22 @@ def get_message_list(messages_map, message_id):
# Reconstruct the chain by following the parentId links
message_list = []
visited_message_ids = set()
while current_message:
message_list.insert(
0, current_message
) # Insert the message at the beginning of the list
message_id = current_message.get("id")
if message_id in visited_message_ids:
# Cycle detected, break to prevent infinite loop
break
if message_id is not None:
visited_message_ids.add(message_id)
message_list.append(current_message)
parent_id = current_message.get("parentId") # Use .get() for safety
current_message = messages_map.get(parent_id) if parent_id else None
message_list.reverse()
return message_list
@@ -202,7 +210,12 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
content = ""
for part in output_parts:
if part.get("type") == "input_text":
content += part.get("text", "")
output_text = part.get("text", "")
content += (
str(output_text)
if not isinstance(output_text, str)
else output_text
)
messages.append(
{
@@ -269,6 +282,24 @@ def get_last_user_message(messages: list[dict]) -> Optional[str]:
return get_content_from_message(message)
def set_last_user_message_content(content: str, messages: list[dict]) -> list[dict]:
"""
Replace the text content of the last user message in-place.
Handles both plain-string and list-of-parts content formats.
"""
for message in reversed(messages):
if message.get("role") == "user":
if isinstance(message.get("content"), list):
for item in message["content"]:
if item.get("type") == "text":
item["text"] = content
break
else:
message["content"] = content
break
return messages
def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]:
for message in reversed(messages):
if message["role"] == "assistant":
+89 -47
View File
@@ -1,3 +1,4 @@
import copy
import time
import logging
import asyncio
@@ -148,63 +149,64 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
]
custom_models = Models.get_all_models()
# Single O(1) lookup: Ollama base names first, then exact IDs (exact wins).
base_model_lookup = {}
for model in models:
if model.get("owned_by") == "ollama":
base_model_lookup.setdefault(model["id"].split(":")[0], model)
base_model_lookup[model["id"]] = model
existing_ids = {m["id"] for m in models}
for custom_model in custom_models:
if custom_model.base_model_id is None:
# Applied directly to a base model
for model in models:
if custom_model.id == model["id"] or (
model.get("owned_by") == "ollama"
and custom_model.id
== model["id"].split(":")[
0
] # Ollama may return model ids in different formats (e.g., 'llama3' vs. 'llama3:7b')
):
if custom_model.is_active:
model["name"] = custom_model.name
model["info"] = custom_model.model_dump()
# Override applied directly to a base model (shares the same ID)
model = base_model_lookup.get(custom_model.id)
# Set action_ids and filter_ids
action_ids = []
filter_ids = []
if model:
if custom_model.is_active:
model["name"] = custom_model.name
model["info"] = custom_model.model_dump()
if "info" in model:
if "meta" in model["info"]:
action_ids.extend(
model["info"]["meta"].get("actionIds", [])
)
filter_ids.extend(
model["info"]["meta"].get("filterIds", [])
)
action_ids = []
filter_ids = []
if "params" in model["info"]:
# Remove params to avoid exposing sensitive info
del model["info"]["params"]
if "info" in model:
if "meta" in model["info"]:
action_ids.extend(
model["info"]["meta"].get("actionIds", [])
)
filter_ids.extend(
model["info"]["meta"].get("filterIds", [])
)
model["action_ids"] = action_ids
model["filter_ids"] = filter_ids
else:
models.remove(model)
if "params" in model["info"]:
del model["info"]["params"]
model["action_ids"] = action_ids
model["filter_ids"] = filter_ids
else:
models.remove(model)
elif custom_model.is_active:
if custom_model.id in existing_ids:
continue
elif custom_model.is_active and (
custom_model.id not in [model["id"] for model in models]
):
# Custom model based on a base model
owned_by = "openai"
connection_type = None
pipe = None
for m in models:
if (
custom_model.base_model_id == m["id"]
or custom_model.base_model_id == m["id"].split(":")[0]
):
owned_by = m.get("owned_by", "unknown")
if "pipe" in m:
pipe = m["pipe"]
connection_type = m.get("connection_type", None)
break
base_model = base_model_lookup.get(custom_model.base_model_id)
if base_model is None:
base_model = base_model_lookup.get(
custom_model.base_model_id.split(":")[0]
)
if base_model:
owned_by = base_model.get("owned_by", "unknown")
if "pipe" in base_model:
pipe = base_model["pipe"]
connection_type = base_model.get("connection_type", None)
model = {
"id": f"{custom_model.id}",
@@ -307,12 +309,52 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
except Exception as e:
log.info(f"Failed to load function module for {function_id}: {e}")
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
default_metadata = (
getattr(request.app.state.config, "DEFAULT_MODEL_METADATA", None) or {}
)
if default_metadata:
for model in models:
info = model.get("info")
if info is None:
model["info"] = {"meta": copy.deepcopy(default_metadata)}
continue
meta = info.setdefault("meta", {})
for key, value in default_metadata.items():
if key == "capabilities":
# Merge capabilities: defaults as base, per-model overrides win
existing = meta.get("capabilities") or {}
meta["capabilities"] = {**value, **existing}
elif meta.get(key) is None:
meta[key] = copy.deepcopy(value)
# Batch-fetch all function valves in one query to avoid N+1 DB hits
# inside get_action_priority (previously called per action × per model).
all_function_valves = Functions.get_function_valves_by_ids(list(all_function_ids))
def get_action_priority(action_id):
try:
function_module = request.app.state.FUNCTIONS.get(action_id)
if function_module and hasattr(function_module, "Valves"):
valves_db = all_function_valves.get(action_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
return getattr(valves, "priority", 0)
except Exception:
pass
return 0
for model in models:
action_ids = [
action_id
for action_id in list(set(model.pop("action_ids", []) + global_action_ids))
if action_id in enabled_action_ids
]
action_ids.sort(key=lambda aid: (get_action_priority(aid), aid))
filter_ids = [
filter_id
for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids))
@@ -435,7 +477,7 @@ def get_filtered_models(models, user, db=None):
if model_info:
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info["user_id"]
or user.id == model_info.get("user_id")
or model["id"] in accessible_model_ids
):
filtered_models.append(model)
+87 -15
View File
@@ -36,11 +36,13 @@ from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupF
from open_webui.config import (
DEFAULT_USER_ROLE,
ENABLE_OAUTH_SIGNUP,
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
OAUTH_PROVIDERS,
ENABLE_OAUTH_ROLE_MANAGEMENT,
ENABLE_OAUTH_GROUP_MANAGEMENT,
ENABLE_OAUTH_GROUP_CREATION,
OAUTH_GROUP_DEFAULT_SHARE,
OAUTH_BLOCKED_GROUPS,
OAUTH_GROUPS_SEPARATOR,
OAUTH_ROLES_SEPARATOR,
@@ -54,6 +56,8 @@ from open_webui.config import (
OAUTH_ADMIN_ROLES,
OAUTH_ALLOWED_DOMAINS,
OAUTH_UPDATE_PICTURE_ON_LOGIN,
OAUTH_UPDATE_NAME_ON_LOGIN,
OAUTH_UPDATE_EMAIL_ON_LOGIN,
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
OAUTH_AUDIENCE,
WEBHOOK_URL,
@@ -69,6 +73,7 @@ from open_webui.env import (
ENABLE_OAUTH_ID_TOKEN_COOKIE,
ENABLE_OAUTH_EMAIL_FALLBACK,
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
OAUTH_MAX_SESSIONS_PER_USER,
)
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
@@ -109,10 +114,14 @@ log = logging.getLogger(__name__)
auth_manager_config = AppConfig()
auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = (
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
)
auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE = OAUTH_GROUP_DEFAULT_SHARE
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
@@ -126,6 +135,8 @@ auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN = OAUTH_UPDATE_NAME_ON_LOGIN
auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
@@ -780,6 +791,16 @@ class OAuthClientManager:
if hasattr(client, "client_secret") and client.client_secret:
refresh_data["client_secret"] = client.client_secret
# Add scope if available in client kwargs (some providers require it on refresh)
if (
hasattr(client, "client_kwargs")
and client.client_kwargs.get("scope")
and getattr(
self.app.state.config, "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", False
)
):
refresh_data["scope"] = client.client_kwargs["scope"]
# Make refresh request
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.post(
@@ -1074,6 +1095,14 @@ class OAuthManager:
if hasattr(client, "client_secret") and client.client_secret:
refresh_data["client_secret"] = client.client_secret
# Add scope if available in client kwargs (some providers require it on refresh)
if (
hasattr(client, "client_kwargs")
and client.client_kwargs.get("scope")
and auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
):
refresh_data["scope"] = client.client_kwargs["scope"]
# Make refresh request
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.post(
@@ -1245,7 +1274,11 @@ class OAuthManager:
name=group_name,
description=f"Group '{group_name}' created automatically via OAuth.",
permissions=default_permissions, # Use default permissions from function args
user_ids=[], # Start with no users, user will be added later by subsequent logic
data={
"config": {
"share": auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE
}
},
)
# Use determined creator ID (admin or fallback to current user)
created_group = Groups.insert_new_group(
@@ -1541,6 +1574,33 @@ class OAuthManager:
# Update the user object in memory as well,
# to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
user.role = determined_role
if auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN:
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
if username_claim:
new_name = user_data.get(username_claim)
if new_name and new_name != user.name:
Users.update_user_by_id(user.id, {"name": new_name}, db=db)
user.name = new_name
log.debug(f"Updated name for user {user.email}")
if auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN:
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
if email_claim:
new_email = user_data.get(email_claim)
if new_email and new_email.lower() != user.email.lower():
existing_user = Users.get_user_by_email(new_email, db=db)
if existing_user:
log.error(
f"Cannot update email to {new_email} for user {user.id} because it is already taken."
)
else:
Auths.update_email_by_id(
user.id, new_email.lower(), db=db
)
user.email = new_email.lower()
log.debug(f"Updated email for user {user.id}")
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
@@ -1679,11 +1739,18 @@ class OAuthManager:
if "expires_in" in token and "expires_at" not in token:
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
# Clean up any existing sessions for this user/provider first
# Enforce max concurrent sessions per user/provider to prevent
# unbounded growth while allowing multi-device usage
sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db)
for session in sessions:
if session.provider == provider:
OAuthSessions.delete_session_by_id(session.id, db=db)
provider_sessions = sorted(
[session for session in sessions if session.provider == provider],
key=lambda session: session.created_at,
reverse=True,
)
# Keep the newest sessions up to the limit, prune the rest
if len(provider_sessions) >= OAUTH_MAX_SESSIONS_PER_USER:
for old_session in provider_sessions[OAUTH_MAX_SESSIONS_PER_USER - 1 :]:
OAuthSessions.delete_session_by_id(old_session.id, db=db)
session = OAuthSessions.create_session(
user_id=user.id,
@@ -1692,17 +1759,22 @@ class OAuthManager:
db=db,
)
response.set_cookie(
key="oauth_session_id",
value=session.id,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if session:
response.set_cookie(
key="oauth_session_id",
value=session.id,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
log.info(
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
)
log.info(
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
)
else:
log.warning(
f"Failed to create OAuth session for user {user.id}, provider {provider}"
)
except Exception as e:
log.error(f"Failed to store OAuth session server-side: {e}")
+2 -2
View File
@@ -187,7 +187,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": bool,
"think": lambda x: x,
}
for key, value in ollama_root_params.items():
@@ -326,7 +326,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": bool,
"think": lambda x: x,
}
# Ollama's options field can contain parameters that should be at the root level.
+12 -1
View File
@@ -8,7 +8,12 @@ import tempfile
import logging
from typing import Any
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE
from open_webui.env import (
PIP_OPTIONS,
PIP_PACKAGE_INDEX_OPTIONS,
OFFLINE_MODE,
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
)
from open_webui.models.functions import Functions
from open_webui.models.tools import Tools
@@ -401,6 +406,12 @@ def get_function_module_from_cache(request, function_id, load_from_db=True):
def install_frontmatter_requirements(requirements: str):
if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS:
log.info(
"ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements."
)
return
if OFFLINE_MODE:
log.info("Offline mode enabled, skipping installation of requirements.")
return
+3 -1
View File
@@ -144,6 +144,7 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
async def convert_streaming_response_ollama_to_openai(ollama_streaming_response):
has_tool_calls = False
async for data in ollama_streaming_response.body_iterator:
data = json.loads(data)
@@ -155,6 +156,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
has_tool_calls = True
done = data.get("done", False)
@@ -166,7 +168,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
model, message_content, reasoning_content, openai_tool_calls, usage
)
if done and openai_tool_calls:
if done and has_tool_calls:
data["choices"][0]["finish_reason"] = "tool_calls"
line = f"data: {json.dumps(data)}\n\n"
@@ -28,6 +28,7 @@ def set_security_headers() -> Dict[str, str]:
- x-frame-options
- x-permitted-cross-domain-policies
- content-security-policy
- reporting-endpoints
Each environment variable is associated with a specific setter function
that constructs the header. If the environment variable is set, the
@@ -47,6 +48,7 @@ def set_security_headers() -> Dict[str, str]:
"XFRAME_OPTIONS": set_xframe,
"XPERMITTED_CROSS_DOMAIN_POLICIES": set_xpermitted_cross_domain_policies,
"CONTENT_SECURITY_POLICY": set_content_security_policy,
"REPORTING_ENDPOINTS": set_reporting_endpoints,
}
for env_var, setter in header_setters.items():
@@ -131,3 +133,8 @@ def set_xpermitted_cross_domain_policies(value: str):
# Set Content-Security-Policy response header
def set_content_security_policy(value: str):
return {"Content-Security-Policy": value}
# Set Reporting-Endpoints response header
def set_reporting_endpoints(value: str):
return {"Reporting-Endpoints": value}
@@ -20,6 +20,7 @@ from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor
from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor
from opentelemetry.trace import Span, StatusCode
from redis import Redis
from redis.cluster import RedisCluster
@@ -204,6 +205,7 @@ class Instrumentor(BaseInstrumentor):
request_hook=aiohttp_request_hook,
response_hook=aiohttp_response_hook,
)
SystemMetricsInstrumentor().instrument()
def _uninstrument(self, **kwargs):
if getattr(self, "instrumentors", None) is None:
@@ -120,12 +120,12 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
# Instruments
request_counter = meter.create_counter(
name="http.server.requests",
description="Total HTTP requests",
description="Counts the total number of inbound HTTP requests.",
unit="1",
)
duration_histogram = meter.create_histogram(
name="http.server.duration",
description="HTTP request duration",
description="Measures the duration of inbound HTTP requests.",
unit="ms",
)
+254 -24
View File
@@ -40,7 +40,7 @@ from open_webui.models.users import UserModel
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.utils.access_control import has_access
from open_webui.utils.access_control import has_access, has_connection_access
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
@@ -60,6 +60,8 @@ from open_webui.tools.builtin import (
search_memories,
add_memory,
replace_memory_content,
delete_memory,
list_memories,
get_current_timestamp,
calculate_timestamp,
search_notes,
@@ -142,25 +144,13 @@ def get_updated_tool_function(function: Callable, extra_params: dict):
return function
def has_tool_server_access(
user: UserModel, server_connection: dict, user_group_ids: set = None
) -> bool:
"""Check if user has access to a tool server (MCP or OpenAPI)."""
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return True
if user_group_ids is None:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
server_config = server_connection.get("config", {})
access_grants = server_config.get("access_grants", [])
return has_access(user.id, "read", access_grants, user_group_ids)
async def get_tools(
request: Request, tool_ids: list[str], user: UserModel, extra_params: dict
) -> dict[str, dict]:
"""Load tools for the given tool_ids, checking access control."""
if not tool_ids:
return {}
tools_dict = {}
# Get user's group memberships for access control checks
@@ -292,7 +282,7 @@ async def get_tools(
)
# Check access control for tool server
if not has_tool_server_access(
if not has_connection_access(
user, tool_server_connection, user_group_ids
):
log.warning(
@@ -389,7 +379,7 @@ async def get_tools(
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
"spec": clean_openai_tool_schema(spec),
# Misc info
"type": "external",
}
@@ -442,6 +432,10 @@ def get_builtin_tools(
# If model has attached knowledge (any type), only provide query_knowledge_files
# Otherwise, provide all KB browsing tools
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", [])
# Merge folder-attached knowledge so builtin tools can search it
folder_knowledge = extra_params.get("__metadata__", {}).get("folder_knowledge")
if folder_knowledge:
model_knowledge = list(model_knowledge or []) + list(folder_knowledge)
if is_builtin_tool_enabled("knowledge"):
if model_knowledge:
# Model has attached knowledge - only allow semantic search within it
@@ -471,7 +465,15 @@ def get_builtin_tools(
# Add memory tools if builtin category enabled AND enabled for this chat
if is_builtin_tool_enabled("memory") and features.get("memory"):
builtin_functions.extend([search_memories, add_memory, replace_memory_content])
builtin_functions.extend(
[
search_memories,
add_memory,
replace_memory_content,
delete_memory,
list_memories,
]
)
# Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
if (
@@ -550,6 +552,7 @@ def get_builtin_tools(
# Generate spec from function
pydantic_model = convert_function_to_pydantic_model(func)
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
spec = clean_openai_tool_schema(spec)
tools_dict[func.__name__] = {
"tool_id": f"builtin:{func.__name__}",
@@ -658,6 +661,44 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
return model
def clean_properties(schema: dict):
if not isinstance(schema, dict):
return
if "anyOf" in schema:
non_null_types = [t for t in schema["anyOf"] if t.get("type") != "null"]
if len(non_null_types) == 1:
schema.update(non_null_types[0])
del schema["anyOf"]
else:
schema["anyOf"] = non_null_types
if "default" in schema and schema["default"] is None:
del schema["default"]
# fix missing type
if "type" not in schema and "anyOf" not in schema and "properties" not in schema:
schema["type"] = "string"
if "properties" in schema:
for prop_name, prop_schema in schema["properties"].items():
clean_properties(prop_schema)
if "items" in schema:
clean_properties(schema["items"])
def clean_openai_tool_schema(spec: dict) -> dict:
import copy
cleaned_spec = copy.deepcopy(spec)
if "parameters" in cleaned_spec:
clean_properties(cleaned_spec["parameters"])
return cleaned_spec
def get_functions_from_tool(tool: object) -> list[Callable]:
return [
getattr(tool, func)
@@ -680,7 +721,9 @@ def get_tool_specs(tool_module: object) -> list[dict]:
)
specs = [
convert_pydantic_model_to_openai_function_spec(function_model)
clean_openai_tool_schema(
convert_pydantic_model_to_openai_function_spec(function_model)
)
for function_model in function_models
]
@@ -756,7 +799,7 @@ def convert_openapi_to_tool_payload(openapi_spec):
f". Possible values: {', '.join(param_schema.get('enum'))}"
)
param_property = {
"type": param_schema.get("type"),
"type": param_schema.get("type") or "string",
"description": description,
}
@@ -764,6 +807,11 @@ def convert_openapi_to_tool_payload(openapi_spec):
if param_schema.get("type") == "array" and "items" in param_schema:
param_property["items"] = param_schema["items"]
# Filter out None values to prevent schema validation errors
param_property = {
k: v for k, v in param_property.items() if v is not None
}
tool["parameters"]["properties"][param_name] = param_property
if param.get("required"):
tool["parameters"]["required"].append(param_name)
@@ -827,6 +875,180 @@ async def get_tool_servers(request: Request):
return tool_servers
async def get_terminal_cwd(
base_url: str,
headers: dict,
cookies: Optional[dict] = None,
) -> Optional[str]:
"""Fetch the current working directory from a terminal server."""
try:
cwd_url = f"{base_url.rstrip('/')}/files/cwd"
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=5),
trust_env=True,
) as session:
async with session.get(
cwd_url, headers=headers, cookies=cookies or {}
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("cwd")
except Exception as e:
log.debug(f"Failed to fetch terminal CWD: {e}")
return None
async def set_terminal_servers(request: Request):
"""Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
# Build server configs compatible with get_tool_servers_data
# Terminal connections store id/name at top level; translate to info dict
server_configs = []
for connection in connections:
if not connection.get("url"):
continue
enabled = connection.get("enabled", True)
server_configs.append(
{
"url": connection.get("url", ""),
"key": connection.get("key", ""),
"auth_type": connection.get("auth_type", "bearer"),
"path": connection.get("path", "/openapi.json"),
"spec_type": "url",
# get_tool_servers_data reads config.enable to filter active servers
"config": {"enable": enabled},
"info": {
"id": connection.get("id", ""),
"name": connection.get("name", ""),
},
}
)
request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs)
if request.app.state.redis is not None:
await request.app.state.redis.set(
"terminal_servers", json.dumps(request.app.state.TERMINAL_SERVERS)
)
return request.app.state.TERMINAL_SERVERS
async def get_terminal_servers(request: Request):
"""Return cached terminal server specs, loading if needed."""
terminal_servers = []
if request.app.state.redis is not None:
try:
terminal_servers = json.loads(
await request.app.state.redis.get("terminal_servers")
)
request.app.state.TERMINAL_SERVERS = terminal_servers
except Exception as e:
log.error(f"Error fetching terminal_servers from Redis: {e}")
if not terminal_servers:
terminal_servers = await set_terminal_servers(request)
return terminal_servers
async def get_terminal_tools(
request: Request,
terminal_id: str,
user: UserModel,
extra_params: dict,
) -> dict[str, dict]:
"""Resolve tools for a terminal server identified by terminal_id.
- Finds the connection in TERMINAL_SERVER_CONNECTIONS
- Checks access_grants
- Loads specs from cache
- Builds callables that route through the terminal proxy
"""
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get("id") == terminal_id), None)
if connection is None:
log.warning(f"Terminal server not found: {terminal_id}")
return {}
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
if not has_connection_access(user, connection, user_group_ids):
log.warning(f"Access denied to terminal {terminal_id} for user {user.id}")
return {}
# Find the cached spec data for this terminal
terminal_servers = await get_terminal_servers(request)
server_data = next(
(s for s in terminal_servers if s.get("id") == terminal_id), None
)
if server_data is None:
log.warning(f"Terminal server spec not found for {terminal_id}")
return {}
specs = server_data.get("specs", [])
if not specs:
return {}
# Build auth headers
auth_type = connection.get("auth_type", "bearer")
cookies = {}
headers = {"Content-Type": "application/json", "X-User-Id": user.id}
if auth_type == "bearer":
headers["Authorization"] = f"Bearer {connection.get('key', '')}"
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = f"Bearer {request.state.token.credentials}"
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = f"Bearer {oauth_token.get('access_token', '')}"
# auth_type == "none": no Authorization header
terminal_cwd = await get_terminal_cwd(connection.get("url", ""), headers, cookies)
tools_dict = {}
for spec in specs:
function_name = spec["name"]
# Inject CWD into run_command description
tool_spec = clean_openai_tool_schema(spec)
if function_name == "run_command" and terminal_cwd:
tool_spec["description"] = (
tool_spec.get("description", "")
+ f"\n\nThe current working directory is: {terminal_cwd}"
)
def make_tool_function(fn_name, srv_data, hdrs, cks):
async def tool_function(**kwargs):
return await execute_tool_server(
url=srv_data["url"],
headers=hdrs,
cookies=cks,
name=fn_name,
params=kwargs,
server_data=srv_data,
)
return tool_function
tool_function = make_tool_function(function_name, server_data, headers, cookies)
callable = get_async_tool_function_and_apply_extra_params(tool_function, {})
tools_dict[function_name] = {
"tool_id": f"terminal:{terminal_id}",
"callable": callable,
"spec": tool_spec,
"type": "terminal",
}
return tools_dict
async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]:
_headers = {
"Accept": "application/json",
@@ -943,6 +1165,11 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
log.error(f"Failed to connect to {url} OpenAPI tool server")
continue
# Guard against invalid or non-OpenAPI specs (e.g., MCP-style configs)
if not isinstance(response, dict) or "paths" not in response:
log.warning(f"Invalid OpenAPI spec from {url}: missing 'paths'")
continue
response = {
"openapi": response,
"info": response.get("info", {}),
@@ -963,7 +1190,7 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
{
"id": str(id),
"idx": idx,
"url": server.get("url"),
"url": (server.get("url") or "").rstrip("/"),
"openapi": openapi_data,
"info": response.get("info"),
"specs": response.get("specs"),
@@ -1024,9 +1251,10 @@ async def execute_tool_server(
if param_in == "path":
path_params[param_name] = params[param_name]
elif param_in == "query":
query_params[param_name] = params[param_name]
if params[param_name] is not None:
query_params[param_name] = params[param_name]
final_url = f"{url}{route_path}"
final_url = f"{url.rstrip('/')}{route_path}"
for key, value in path_params.items():
final_url = final_url.replace(f"{{{key}}}", str(value))
@@ -1096,6 +1324,8 @@ def get_tool_server_url(url: Optional[str], path: str) -> str:
if "://" in path:
# If it contains "://", it's a full URL
return path
if url:
url = url.rstrip("/")
if not path.startswith("/"):
# Ensure the path starts with a slash
path = f"/{path}"
+10 -9
View File
@@ -1,8 +1,8 @@
# Minimal requirements for backend to run
# WIP: use this as a reference to build a minimal docker image
fastapi==0.128.5
uvicorn[standard]==0.40.0
fastapi==0.135.1
uvicorn[standard]==0.41.0
pydantic==2.12.5
python-multipart==0.0.22
itsdangerous==2.2.0
@@ -13,7 +13,7 @@ cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.11.0
authlib==1.6.7
authlib==1.6.9
requests==2.32.5
aiohttp==3.13.2 # do not update to 3.13.3 - broken
@@ -25,12 +25,12 @@ Brotli==1.1.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
sqlalchemy==2.0.46
alembic==1.18.3
sqlalchemy==2.0.48
alembic==1.18.4
peewee==3.19.0
peewee-migrate==1.14.3
pycrdt==0.12.46
pycrdt==0.12.47
redis
APScheduler==3.11.2
@@ -42,14 +42,15 @@ asgiref==3.11.1
mcp==1.26.0
openai
langchain==1.2.9
langchain==1.2.10
langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
langchain-text-splitters==1.1.1
fake-useragent==2.2.0
chromadb==1.4.1
chromadb==1.5.2
black==26.1.0
pydub
chardet==5.2.0
beautifulsoup4
+40 -39
View File
@@ -1,5 +1,5 @@
fastapi==0.128.5
uvicorn[standard]==0.40.0
fastapi==0.135.1
uvicorn[standard]==0.41.0
pydantic==2.12.5
python-multipart==0.0.22
itsdangerous==2.2.0
@@ -10,7 +10,7 @@ cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.11.0
authlib==1.6.7
authlib==1.6.9
requests==2.32.5
aiohttp==3.13.2 # do not update to 3.13.3 - broken
@@ -23,17 +23,17 @@ httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
sqlalchemy==2.0.46
alembic==1.18.3
sqlalchemy==2.0.48
alembic==1.18.4
peewee==3.19.0
peewee-migrate==1.14.3
pycrdt==0.12.46
pycrdt==0.12.47
redis
APScheduler==3.11.2
RestrictedPython==8.1
pytz==2025.2
pytz==2026.1.post1
loguru==0.7.3
asgiref==3.11.1
@@ -44,37 +44,38 @@ mcp==1.26.0
openai
anthropic
google-genai==1.62.0
google-genai==1.66.0
langchain==1.2.9
langchain==1.2.10
langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
langchain-text-splitters==1.1.1
fake-useragent==2.2.0
chromadb==1.4.1
weaviate-client==4.19.2
chromadb==1.5.2
weaviate-client==4.20.3
opensearch-py==3.1.0
transformers==5.1.0
sentence-transformers==5.2.2
transformers==5.3.0
sentence-transformers==5.2.3
accelerate
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.2
ftfy==6.3.1
chardet==5.2.0
pypdf==6.7.0
fpdf2==2.8.5
pymdown-extensions==10.20.1
pypdf==6.7.5
fpdf2==2.8.7
pymdown-extensions==10.21
docx2txt==0.9
python-pptx==1.0.2
unstructured==0.18.31
msoffcrypto-tool==6.0.0
nltk==3.9.2
Markdown==3.10.1
nltk==3.9.3
Markdown==3.10.2
beautifulsoup4
pypandoc==1.16.2
pandas==3.0.0
pandas==3.0.1
openpyxl==3.1.5
pyxlsb==1.0.10
xlrd==2.0.2
@@ -83,12 +84,12 @@ psutil
sentencepiece
soundfile==0.13.1
pillow==12.1.0
pillow==12.1.1
opencv-python-headless==4.13.0.92
rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2
onnxruntime==1.24.1
onnxruntime==1.24.3
faster-whisper==1.2.1
black==26.1.0
@@ -96,10 +97,10 @@ youtube-transcript-api==1.2.4
pytube==15.0.0
pydub
ddgs==9.10.0
ddgs==9.11.2
azure-ai-documentintelligence==1.0.2
azure-identity==1.25.1
azure-identity==1.25.2
azure-storage-blob==12.28.0
azure-search-documents==11.6.0
@@ -117,10 +118,10 @@ psycopg2-binary==2.9.11
pgvector==0.4.2
PyMySQL==1.1.2
boto3==1.42.44
boto3==1.42.62
pymilvus==2.6.8
qdrant-client==1.16.2
pymilvus==2.6.9
qdrant-client==1.17.0
playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.3.0
pinecone==6.0.2
@@ -140,17 +141,17 @@ pytest-docker~=3.2.5
ldap3==2.9.1
## Firecrawl
firecrawl-py==4.14.0
firecrawl-py==4.18.0
## Trace
opentelemetry-api==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-exporter-otlp==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-sqlalchemy==0.60b1
opentelemetry-instrumentation-redis==0.60b1
opentelemetry-instrumentation-requests==0.60b1
opentelemetry-instrumentation-logging==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-instrumentation-aiohttp-client==0.60b1
opentelemetry-api==1.40.0
opentelemetry-sdk==1.40.0
opentelemetry-exporter-otlp==1.40.0
opentelemetry-instrumentation==0.61b0
opentelemetry-instrumentation-fastapi==0.61b0
opentelemetry-instrumentation-sqlalchemy==0.61b0
opentelemetry-instrumentation-redis==0.61b0
opentelemetry-instrumentation-requests==0.61b0
opentelemetry-instrumentation-logging==0.61b0
opentelemetry-instrumentation-httpx==0.61b0
opentelemetry-instrumentation-aiohttp-client==0.61b0
+29 -22
View File
@@ -26,33 +26,40 @@ We appreciate the community's interest in identifying potential vulnerabilities.
2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
3. **In-Depth Understanding Required**: Reports must reflect a clear understanding of the codebase and provide specific details about the vulnerability, including the affected components and potential impacts.
3. **In-Depth Understanding**: Reports must reflect a clear understanding of the codebase, how Open WebUI is used and provide specific details about the vulnerability, including the affected components and potential impacts.
4. **Proof of Concept (PoC) is Mandatory**: Each submission must include a well-documented proof of concept (PoC) that demonstrates the vulnerability. If confidentiality is a concern, reporters are encouraged to create a private fork of the repository and share access with the maintainers. Reports lacking valid evidence may be disregarded.
> [!NOTE]
> A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show:
>
> 1. What security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
> 2. How this vulnerability was abused
> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
> 2. How this vulnerability is triggered/abused (inputs, endpoints, UI actions, etc.)
> 3. What actions the attacker can now perform
>
> **Examples of valid PoCs:**
>
> - Step-by-step reproduction instructions with exact commands
> - Complete exploit code with detailed execution instructions
> - Screenshots/videos demonstrating the exploit (supplementary to written steps)
> 4. What data/action becomes possible that should not be possible
> 5. Exact steps and commands to reproduce (copy/paste runnable where possible), expected result vs. actual result
>
> **Failure to provide a reproducible PoC may lead to closure of the report**
>
> We will notify you, if we struggle to reproduce the exploit using your PoC to allow you to improve your PoC.
> If we cannot reproduce the issue from your PoC, we may ask for clarification or improvements
> However, if we repeatedly cannot reproduce the exploit using the PoC, the report may be closed.
5. **Required Patch or Actionable Remediation Plan Submission**: Along with the PoC, reporters must provide a patch or some actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly.
5. **Remediation is required**:
6. **Streamlined Merging Process**: When vulnerability reports meet the above criteria, we can consider provided patches for immediate merging, similar to regular pull requests. Well-structured and thorough submissions will expedite the process of enhancing our security.
Along with the PoC, you must provide **either**:
7. **Default Configuration Testing**: All vulnerability reports MUST be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception:
1. **A patch/PR**, **or**
2. **a remediation plan** ("actionable steps") that a maintainer can apply without guesswork.
Your remediation guidance can include, for example:
- The **likely root cause** (what's wrong and where)
- The **location(s)** to change (file/module/function names if known)
- The **recommended fix approach** (validation/sanitization rules, auth checks, safe defaults, etc.)
- Any **security tradeoffs** or potential regressions to watch for
6. **Default Configuration Testing**: All vulnerability reports must be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception:
> [!NOTE]
> **Note**: If you believe you have found a security issue that
@@ -61,26 +68,26 @@ We appreciate the community's interest in identifying potential vulnerabilities.
> 2. represents a genuine bypass of intended security controls, **or**
> 3. works only with non-default configurations, **but the configuration in question is likely to be used by production deployments**, **then we absolutely want to hear about it.** This policy is intended to filter configuration issues and deployment problems, not to discourage legitimate security research.
8. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection.
7. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, extensible, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection.
9. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection.
8. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection.
> [!WARNING]
>
> **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report.
10. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. Admins have full system control and are expected to understand the security implications of their actions and configurations. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.**
9. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. **Admins have full system control and are expected to understand the security implications of their actions and configurations**. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.**
> [!NOTE]
> Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions,
> **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research.
11. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **YOU MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it.
10. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **you MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it.
> [!NOTE]
> AI-aided vulnerability reports **will not be rejected by us by default**. But:
>
> - If we suspect you used AI (but you did not disclose it to us), we will be asking tough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself.
> - If we suspect you used AI (but you did not disclose it to us), we will be asking thorough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself.
> - If we suspect you used AI (but you did not disclose it to us) **and** your report ends up being invalid/not a vulnerability/not reproducible, then you **may be banned** from reporting future vulnerabilities.
>
> This measure was necessary due to the extreme rise in clearly AI written vulnerability reports, where the vast majority of them
@@ -91,9 +98,9 @@ We appreciate the community's interest in identifying potential vulnerabilities.
> - violated any of the rules outlined here
> - had a clear lack of understanding of Open WebUI
> - wrote comments with conflicting information
> - used illogical arguments
> - used illogical and conflicting arguments
**Non-compliant submissions will be closed, and repeat extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
**Non-compliant submissions will be closed, and repeat or extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
## Where to report the vulnerability
@@ -119,12 +126,12 @@ If your concern does not meet the vulnerability requirements outlined above, is
- Feature requests for optional security enhancements (2FA, audit logging, etc.)
- General security questions about production deployment
Please use the adequate channel for your specific issue - e.g. best-practice guidance or additional documentation needs into the Documentation Repository, and feature requests into the Main Repository as an issue or discussion.
Please use the adequate channel for your specific issue - e.g. best-practice guidance or **dditional documentation needs into the [Documentation Repository](https://github.com/open-webui/docs)**, and **feature requests into the Main Repository as an issue or discussion**.
We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon.
For any other immediate concerns, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s).
For any other immediate concerns and questions, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s).
---
_Last updated on **2025-11-06**._
_Last updated on **2026-02-25**._
+570 -467
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.8.3",
"version": "0.8.9",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@@ -66,6 +66,7 @@
"@sveltejs/svelte-virtual-list": "^3.0.1",
"@tiptap/core": "^3.0.7",
"@tiptap/extension-bubble-menu": "^2.26.1",
"@tiptap/extension-code": "^3.0.7",
"@tiptap/extension-code-block-lowlight": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.4.5",
"@tiptap/extension-file-handler": "^3.0.7",
@@ -82,6 +83,9 @@
"@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0",
"async": "^3.2.5",
@@ -107,6 +111,7 @@
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"jspdf": "^4.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"kokoro-js": "^1.1.1",
"leaflet": "^1.9.4",
@@ -130,8 +135,10 @@
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.28.2",
"shiki": "^4.0.1",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6",
"sql.js": "^1.14.1",
"svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7",
"turndown": "^7.2.0",
+28 -28
View File
@@ -6,8 +6,8 @@ authors = [
]
license = { file = "LICENSE" }
dependencies = [
"fastapi==0.128.5",
"uvicorn[standard]==0.40.0",
"fastapi==0.135.1",
"uvicorn[standard]==0.41.0",
"pydantic==2.12.5",
"python-multipart==0.0.22",
"itsdangerous==2.2.0",
@@ -18,7 +18,7 @@ dependencies = [
"bcrypt==5.0.0",
"argon2-cffi==25.1.0",
"PyJWT[crypto]==2.11.0",
"authlib==1.6.7",
"authlib==1.6.9",
"requests==2.32.5",
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
@@ -31,15 +31,15 @@ dependencies = [
"starsessions[redis]==2.2.1",
"python-mimeparse==2.0.0",
"sqlalchemy==2.0.46",
"alembic==1.18.3",
"sqlalchemy==2.0.48",
"alembic==1.18.4",
"peewee==3.19.0",
"peewee-migrate==1.14.3",
"pycrdt==0.12.46",
"pycrdt==0.12.47",
"redis",
"pytz==2025.2",
"pytz==2026.1.post1",
"APScheduler==3.11.2",
"RestrictedPython==8.1",
@@ -51,38 +51,38 @@ dependencies = [
"openai",
"anthropic",
"google-genai==1.62.0",
"google-genai==1.66.0",
"langchain==1.2.9",
"langchain==1.2.10",
"langchain-community==0.4.1",
"langchain-classic==1.0.1",
"langchain-text-splitters==1.1.0",
"langchain-text-splitters==1.1.1",
"fake-useragent==2.2.0",
"chromadb==1.4.1",
"chromadb==1.5.2",
"opensearch-py==3.1.0",
"PyMySQL==1.1.2",
"boto3==1.42.44",
"boto3==1.42.62",
"transformers==5.1.0",
"sentence-transformers==5.2.2",
"transformers==5.3.0",
"sentence-transformers==5.2.3",
"accelerate",
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
"einops==0.8.2",
"ftfy==6.3.1",
"chardet==5.2.0",
"pypdf==6.7.0",
"fpdf2==2.8.5",
"pymdown-extensions==10.20.1",
"pypdf==6.7.5",
"fpdf2==2.8.7",
"pymdown-extensions==10.21",
"docx2txt==0.9",
"python-pptx==1.0.2",
"unstructured==0.18.31",
"msoffcrypto-tool==6.0.0",
"nltk==3.9.2",
"Markdown==3.10.1",
"nltk==3.9.3",
"Markdown==3.10.2",
"pypandoc==1.16.2",
"pandas==3.0.0",
"pandas==3.0.1",
"openpyxl==3.1.5",
"pyxlsb==1.0.10",
"xlrd==2.0.2",
@@ -92,12 +92,12 @@ dependencies = [
"soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.2",
"pillow==12.1.0",
"pillow==12.1.1",
"opencv-python-headless==4.13.0.92",
"rapidocr-onnxruntime==1.4.4",
"rank-bm25==0.2.2",
"onnxruntime==1.24.1",
"onnxruntime==1.24.3",
"faster-whisper==1.2.1",
"black==26.1.0",
@@ -105,7 +105,7 @@ dependencies = [
"pytube==15.0.0",
"pydub",
"ddgs==9.10.0",
"ddgs==9.11.2",
"google-api-python-client",
"google-auth-httplib2",
@@ -114,7 +114,7 @@ dependencies = [
"googleapis-common-protos==1.72.0",
"google-cloud-storage==3.9.0",
"azure-identity==1.25.1",
"azure-identity==1.25.2",
"azure-storage-blob==12.28.0",
"ldap3==2.9.1",
@@ -150,15 +150,15 @@ all = [
"playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
"elasticsearch==9.3.0",
"qdrant-client==1.16.2",
"qdrant-client==1.17.0",
"weaviate-client==4.19.2",
"pymilvus==2.6.8",
"weaviate-client==4.20.3",
"pymilvus==2.6.9",
"pinecone==6.0.2",
"oracledb==3.4.2",
"colbert-ai==0.2.22",
"firecrawl-py==4.14.0",
"firecrawl-py==4.18.0",
"azure-search-documents==11.6.0",
]
+6 -12
View File
@@ -332,12 +332,9 @@ input[type='number'] {
}
.codespan {
color: #eb5757;
border-width: 0px;
padding: 3px 8px;
font-size: 0.8em;
font-weight: 600;
@apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5;
padding: 0.15rem 0.3rem;
font-size: 0.85em;
@apply font-mono rounded-md text-gray-800 bg-gray-100 dark:text-gray-200 dark:bg-gray-800 mx-0.5;
}
.svelte-flow {
@@ -566,12 +563,9 @@ input[type='number'] {
}
.tiptap p code {
color: #eb5757;
border-width: 0px;
padding: 3px 8px;
font-size: 0.8em;
font-weight: 600;
@apply rounded-md dark:bg-gray-800 bg-gray-50 mx-0.5;
padding: 0.15rem 0.3rem;
font-size: 0.85em;
@apply font-mono rounded-md text-gray-800 bg-gray-50 dark:text-gray-200 dark:bg-gray-800 mx-0.5;
}
/* Code styling */
+1 -1
View File
@@ -26,7 +26,7 @@
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" content="#171717" />
<meta name="robots" content="noindex,nofollow" />
+57
View File
@@ -172,6 +172,63 @@ export const setToolServerConnections = async (token: string, connections: objec
return res;
};
export const getTerminalServerConnections = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setTerminalServerConnections = async (token: string, connections: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...connections
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const verifyToolServerConnection = async (token: string, connection: object) => {
let error = null;
+1
View File
@@ -4,6 +4,7 @@ type FolderForm = {
name?: string;
data?: Record<string, any>;
meta?: Record<string, any>;
parent_id?: string | null;
};
export const createNewFolder = async (token: string, folderForm: FolderForm) => {
+7 -2
View File
@@ -281,7 +281,12 @@ export const updateModelById = async (token: string, id: string, model: object)
return res;
};
export const updateModelAccessGrants = async (token: string, id: string, accessGrants: any[]) => {
export const updateModelAccessGrants = async (
token: string,
id: string,
name: string,
accessGrants: any[]
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/access/update`, {
@@ -291,7 +296,7 @@ export const updateModelAccessGrants = async (token: string, id: string, accessG
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ id, access_grants: accessGrants })
body: JSON.stringify({ id, name, access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
+28
View File
@@ -395,6 +395,34 @@ export const setProductionPromptVersion = async (
return res;
};
export const togglePromptById = async (token: string, promptId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/toggle`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deletePromptById = async (token: string, promptId: string) => {
let error = null;
+338
View File
@@ -0,0 +1,338 @@
export type FileEntry = {
name: string;
type: 'file' | 'directory';
size?: number;
modified?: number;
};
export type ListeningPort = {
port: number;
pid: number | null;
process: string | null;
};
export type TerminalFeatures = {
terminal?: boolean;
};
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type TerminalServer = {
id: string;
url: string;
name: string;
};
export const getTerminalServers = async (token: string): Promise<TerminalServer[]> => {
const res = await fetch(`${WEBUI_API_BASE_URL}/terminals/`, {
headers: {
Authorization: `Bearer ${token}`
}
}).catch(() => null);
if (!res || !res.ok) return [];
return res.json().catch(() => []);
};
export const getTerminalConfig = async (
baseUrl: string,
apiKey: string
): Promise<{ features: TerminalFeatures } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/api/config`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
if (!res || !res.ok) return null;
return res.json().catch(() => null);
};
export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
if (!res || !res.ok) return null;
const json = await res.json().catch(() => null);
return json?.cwd ?? null;
};
export const listFiles = async (
baseUrl: string,
apiKey: string,
path: string = '/'
): Promise<FileEntry[] | null> => {
// The endpoint uses `directory` as the query param name
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal listFiles error:', err);
return null;
});
return res?.entries ?? null;
};
export const readFile = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch((err) => {
console.error('open-terminal readFile error:', err);
return null;
});
if (!res || !res.ok) return null;
const contentType = res.headers.get('content-type') ?? '';
if (contentType.startsWith('image/') || contentType.startsWith('application/octet')) {
// Binary — return a placeholder
return `[Binary file: ${contentType}]`;
}
// Text files: endpoint returns JSON { path, total_lines, content }
// Binary image files: endpoint returns raw bytes (handled above)
const json = await res.json().catch(() => null);
return json?.content ?? null;
};
export const downloadFileBlob = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
if (!res || !res.ok) return null;
const filename = path.split('/').pop() ?? 'file';
const blob = await res.blob();
return { blob, filename };
};
export const uploadToTerminal = async (
baseUrl: string,
apiKey: string,
directory: string,
file: File
): Promise<{ path: string; size: number } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`;
const body = new FormData();
body.append('file', file);
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal uploadToTerminal error:', err);
return null;
});
return res;
};
export const createDirectory = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<{ path: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal createDirectory error:', err);
return null;
});
return res;
};
export const deleteEntry = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<{ path: string; type: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` }
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal deleteEntry error:', err);
return null;
});
return res;
};
export const setCwd = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<{ cwd: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal setCwd error:', err);
return null;
});
return res;
};
export const moveEntry = async (
baseUrl: string,
apiKey: string,
source: string,
destination: string
): Promise<{ source: string; destination: string } | { error: string }> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/move`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ source, destination })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error('open-terminal moveEntry error:', err);
return { error: err?.detail ?? 'Move failed' };
});
return res;
};
export const getListeningPorts = async (
baseUrl: string,
apiKey: string
): Promise<ListeningPort[]> => {
const url = `${baseUrl.replace(/\/$/, '')}/ports`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
if (!res || !res.ok) return [];
const json = await res.json().catch(() => null);
return json?.ports ?? [];
};
export const getPortProxyUrl = (baseUrl: string, port: number, path: string = ''): string => {
return `${baseUrl.replace(/\/$/, '')}/proxy/${port}/${path}`;
};
// ---------------------------------------------------------------------------
// Notebook execution
// ---------------------------------------------------------------------------
export const createNotebookSession = async (
baseUrl: string,
apiKey: string,
path: string
): Promise<{ id: string; kernel: string; status: string } | { error: string }> => {
const url = `${baseUrl.replace(/\/$/, '')}/notebooks`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path })
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
return { error: body?.detail ?? `HTTP ${res.status}` };
}
return res.json();
})
.catch((err) => {
console.error('open-terminal createNotebookSession error:', err);
return { error: 'Connection failed' };
});
return res;
};
export const executeNotebookCell = async (
baseUrl: string,
apiKey: string,
sessionId: string,
cellIndex: number,
source?: string
): Promise<{ status: string; execution_count?: number; outputs: any[] } | { error: string }> => {
const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}/execute`;
const body: Record<string, any> = { cell_index: cellIndex };
if (source !== undefined) body.source = source;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
return { error: body?.detail ?? `HTTP ${res.status}` };
}
return res.json();
})
.catch((err) => {
console.error('open-terminal executeNotebookCell error:', err);
return { error: 'Connection failed' };
});
return res;
};
export const stopNotebookSession = async (
baseUrl: string,
apiKey: string,
sessionId: string
): Promise<boolean> => {
const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}`;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
return res?.ok ?? false;
};
+17 -4
View File
@@ -309,14 +309,27 @@
bind:value={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
list={ollama ? undefined : 'suggestions'}
required
/>
{#if !ollama}
<datalist id="suggestions">
<option value="https://api.openai.com/v1" />
<option value="https://api.anthropic.com/v1" />
<option value="https://generativelanguage.googleapis.com/v1beta/openai" />
<option value="https://api.mistral.ai/v1" />
<option value="https://api.groq.com/openai/v1" />
<option value="https://openrouter.ai/api/v1" />
<option value="https://api.x.ai/v1" />
</datalist>
{/if}
</div>
</div>
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
@@ -686,7 +699,7 @@
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
@@ -695,9 +708,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -7,7 +7,7 @@
</script>
<div class="px-3">
<div class="text-center dark:text-white text-2xl font-medium z-50">
<div class="text-center dark:text-white text-2xl font-medium z-50" role="heading" aria-level="2">
{#if title}
{title}
{:else}
@@ -0,0 +1,353 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
export let show = false;
export let edit = false;
export let admin = false;
export let connection = null;
export let onSubmit: Function = () => {};
export let onDelete: () => void = () => {};
let url = '';
let key = '';
let name = '';
let id = '';
let auth_type = 'bearer';
let path = '/openapi.json';
let enabled = false;
let showAdvanced = false;
let showAccessControlModal = false;
let accessGrants: any[] = [];
const init = () => {
if (connection) {
id = connection?.id ?? '';
url = connection.url;
key = connection?.key ?? '';
name = connection?.name ?? '';
auth_type = connection?.auth_type ?? 'bearer';
path = connection?.path ?? '/openapi.json';
enabled = connection?.enabled ?? true;
accessGrants = connection?.config?.access_grants ?? [];
} else {
id = '';
url = '';
key = '';
name = '';
auth_type = 'bearer';
path = '/openapi.json';
enabled = false;
accessGrants = [];
}
};
$: if (show) {
init();
}
const submitHandler = () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
return;
}
// Remove trailing slash
url = url.replace(/\/$/, '');
const result = {
...(admin && id.trim() ? { id: id.trim() } : {}),
url,
key,
name,
path,
auth_type,
enabled: enabled,
config: {
...(admin ? { access_grants: accessGrants } : {})
}
};
onSubmit(result);
show = false;
};
</script>
<Modal size="sm" bind:show>
<div>
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<h1 class="text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Terminal Connection')}
{:else}
{$i18n.t('Add Terminal Connection')}
{/if}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form class="flex flex-col w-full" on:submit|preventDefault={submitHandler}>
<div class="px-1">
<div class="flex gap-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="terminal-name"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="terminal-name"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('My Terminal')}
autocomplete="off"
/>
</div>
</div>
{#if admin}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="terminal-id"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('ID')}
<span class="opacity-50">({$i18n.t('optional')})</span></label
>
</div>
<div class="flex flex-1 items-center">
<input
id="terminal-id"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={id}
placeholder="auto"
autocomplete="off"
/>
</div>
</div>
{/if}
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="terminal-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="terminal-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder="http://localhost:9900"
required
autocomplete="off"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<button
type="button"
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
on:click={() => (showAdvanced = !showAdvanced)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Advanced')}
</button>
{#if admin}
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
<div class="text-xs font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
{/if}
</div>
{#if showAdvanced}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex flex-1 items-center">
<div class="flex-1 flex items-center">
<label for="openapi-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="openapi-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Auth')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if admin}
<option value="session">{$i18n.t('Session')}</option>
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
<div></div>
<div class="flex gap-1.5">
{#if edit}
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
onDelete();
show = false;
}}
>
{$i18n.t('Delete')}
</button>
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</Modal>
<AccessControlModal bind:show={showAccessControlModal} bind:accessGrants />
+238 -202
View File
@@ -19,7 +19,8 @@
import Tags from './common/Tags.svelte';
import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
import AccessControl from './workspace/common/AccessControl.svelte';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Textarea from './common/Textarea.svelte';
@@ -58,6 +59,8 @@
let enable = true;
let loading = false;
let showAdvanced = false;
let showAccessControlModal = false;
const registerOAuthClientHandler = async () => {
if (url === '') {
@@ -439,30 +442,94 @@
}}
>
<div class="px-1">
{#if !direct}
<div class="flex gap-2 mb-1.5">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
<div class="flex gap-2 mb-1.5">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
<div class="">
<button
on:click={() => {
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if ['', 'openapi'].includes(type)}
{$i18n.t('OpenAPI')}
{:else if type === 'mcp'}
{$i18n.t('MCP')}
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
{/if}
</button>
</div>
<div class="">
<button
on:click={() => {
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if ['', 'openapi'].includes(type)}
{$i18n.t('OpenAPI')}
{:else if type === 'mcp'}
{$i18n.t('MCP')}
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
{/if}
</button>
</div>
</div>
{/if}
</div>
<div class="flex gap-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="enter-name"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="enter-name"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
autocomplete="off"
/>
</div>
</div>
{#if !direct}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="enter-id"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('ID')}
{#if type !== 'mcp'}<span class="opacity-50">({$i18n.t('optional')})</span
>{/if}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="enter-id"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={id}
placeholder="auto"
autocomplete="off"
required={type === 'mcp'}
/>
</div>
</div>
{/if}
</div>
<div class="flex flex-col w-full mt-1 mb-1.5">
<label
for="description"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Description')}</label
>
<div class="flex-1">
<input
id="description"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
autocomplete="off"
/>
</div>
</div>
<div class="flex gap-2">
<div class="flex flex-col w-full">
@@ -490,7 +557,7 @@
className="shrink-0 flex items-center mr-1"
>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
@@ -520,81 +587,6 @@
</div>
</div>
{#if ['', 'openapi'].includes(type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
<option value="json">{$i18n.t('JSON')}</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if spec_type === 'url'}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
required
rows="5"
/>
</div>
{/if}
</div>
</div>
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
@@ -702,104 +694,152 @@
</div>
</div>
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<label
for="enter-id"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('ID')}
{#if type !== 'mcp'}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
{/if}
</label>
<div class="flex-1">
<input
id="enter-id"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={id}
placeholder={$i18n.t('Enter ID')}
autocomplete="off"
required={type === 'mcp'}
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="enter-name"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}
</label>
<div class="flex-1">
<input
id="enter-name"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<label
for="description"
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
>{$i18n.t('Description')}</label
<div class="flex items-center justify-between">
<button
type="button"
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
on:click={() => (showAdvanced = !showAdvanced)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
>
<div class="flex-1">
<input
id="description"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
autocomplete="off"
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Advanced')}
</button>
{#if !direct}
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
<div class="text-xs font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
{/if}
</div>
{#if showAdvanced}
{#if ['', 'openapi'].includes(type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
<option value="json">{$i18n.t('JSON')}</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if spec_type === 'url'}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
required
rows="5"
/>
</div>
{/if}
</div>
</div>
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
{/if}
{#if !direct}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full mt-2">
<label
@@ -819,12 +859,6 @@
/>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2">
<AccessControl bind:accessGrants />
</div>
{/if}
</div>
@@ -864,7 +898,7 @@
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
@@ -873,9 +907,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -885,3 +919,5 @@
</div>
</div>
</Modal>
<AccessControlModal bind:show={showAccessControlModal} bind:accessGrants />
+5 -7
View File
@@ -38,15 +38,13 @@
<Modal bind:show size="xl">
<div class="px-6 pt-5 dark:text-white text-black">
<div class="flex justify-between items-start">
<div class="text-xl font-medium">
<h2 class="text-xl font-medium m-0">
{$i18n.t("What's New in")}
{$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
</div>
</h2>
<button class="self-center" on:click={closeModal} aria-label={$i18n.t('Close')}>
<XMark className={'size-5'}>
<p class="sr-only">{$i18n.t('Close')}</p>
</XMark>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex items-center mt-1">
@@ -64,9 +62,9 @@
{#if changelog}
{#each Object.keys(changelog) as version}
<div class=" mb-3 pr-2">
<div class="font-semibold text-xl mb-1 dark:text-white">
<h3 class="font-semibold text-xl mb-1 dark:text-white m-0">
v{version} - {changelog[version].date}
</div>
</h3>
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
+4 -3
View File
@@ -67,6 +67,7 @@
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
@@ -103,7 +104,7 @@
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
@@ -112,9 +113,9 @@
{$i18n.t('Import')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
+4 -4
View File
@@ -2,8 +2,8 @@
import { WEBUI_BASE_URL } from '$lib/constants';
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { createEventDispatcher, onMount } from 'svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@@ -82,9 +82,9 @@
});
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
role="status"
aria-live="polite"
class="group relative flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
on:dragstart|preventDefault
on:pointerdown={onPointerDown}
@@ -118,7 +118,7 @@
{/if}
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
{@html DOMPurify.sanitize(marked(content))}
{@html DOMPurify.sanitize(marked(DOMPurify.sanitize(content, { ALLOWED_TAGS: [] })))}
</div>
</div>
</div>
+3 -3
View File
@@ -87,15 +87,15 @@
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
<button
aria-labelledby="get-started"
aria-label={$i18n.t('Get started')}
class="relative z-20 flex p-1 rounded-full bg-white/5 hover:bg-white/10 transition font-medium text-sm"
on:click={() => {
getStartedHandler();
}}
>
<ArrowRightCircle className="size-6" />
<ArrowRightCircle className="size-6" aria-hidden="true" />
</button>
<div id="get-started" class="mt-1.5 font-primary text-base font-medium">
<div class="mt-1.5 font-primary text-base font-medium" aria-hidden="true">
{$i18n.t(`Get started`)}
</div>
</div>
@@ -24,12 +24,12 @@
// Time period - persist in localStorage
let selectedPeriod =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d';
const periods = [
{ value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: 'all', label: 'All time' }
$: periods = [
{ value: '24h', label: $i18n.t('Last 24 hours') },
{ value: '7d', label: $i18n.t('Last 7 days') },
{ value: '30d', label: $i18n.t('Last 30 days') },
{ value: '90d', label: $i18n.t('Last 90 days') },
{ value: 'all', label: $i18n.t('All time') }
];
// User group filter
@@ -158,6 +158,11 @@
if (modelOrderBy === 'name') {
return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
}
if (modelOrderBy === 'tokens') {
const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0;
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
@@ -167,6 +172,11 @@
const nameB = b.name || b.user_id;
return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
}
if (userOrderBy === 'tokens') {
const aTokens = a.total_tokens ?? 0;
const bTokens = b.total_tokens ?? 0;
return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
return userDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
@@ -191,7 +201,7 @@
{#if groups.length > 0}
<select
bind:value={selectedGroupId}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
<option value={null}>{$i18n.t('All Users')}</option>
{#each groups as group}
@@ -201,10 +211,10 @@
{/if}
<select
bind:value={selectedPeriod}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
{#each periods as period}
<option value={period.value}>{$i18n.t(period.label)}</option>
<option value={period.value}>{period.label}</option>
{/each}
</select>
</div>
@@ -264,7 +274,7 @@
{@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }}
<div class="mb-4">
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5">
{$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')}
{selectedPeriod === '24h' ? $i18n.t('Hourly Messages') : $i18n.t('Daily Messages')}
</div>
<ChartLine
data={dailyStats}
@@ -329,8 +339,42 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if modelOrderBy === 'tokens'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-16"
on:click={() => toggleModelSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
%
{#if modelOrderBy === 'percentage'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@@ -349,6 +393,9 @@
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<span class="truncate max-w-[150px]">{model.name}</span>
</div>
@@ -422,7 +469,24 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if userOrderBy === 'tokens'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@@ -435,6 +499,9 @@
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
alt={user.name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/user.png';
}}
/>
<span class="truncate max-w-[150px]"
>{user.name || user.email || user.user_id.substring(0, 8)}</span
@@ -114,7 +114,22 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@@ -130,7 +145,10 @@
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover"
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
</div>
@@ -109,7 +109,22 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
+10 -12
View File
@@ -54,15 +54,14 @@
id="users-tabs-container"
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
<a
id="leaderboard"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/evaluations/leaderboard"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'leaderboard'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/evaluations/leaderboard');
}}
>
<div class=" self-center mr-2">
<svg
@@ -79,17 +78,16 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
</button>
</a>
<button
<a
id="feedback"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/evaluations/feedback"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'feedback'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/evaluations/feedback');
}}
>
<div class=" self-center mr-2">
<svg
@@ -106,7 +104,7 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Feedback')}</div>
</button>
</a>
</div>
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
@@ -32,7 +32,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('delete');
show = false;
@@ -180,7 +180,10 @@
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
alt={model.name}
class="size-5 rounded-full object-cover"
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-medium text-gray-800 dark:text-gray-200 line-clamp-1"
+5 -2
View File
@@ -83,7 +83,7 @@
}
const setFilteredItems = () => {
filteredItems = functions
filteredItems = (functions ?? [])
.filter(
(f) =>
(selectedType !== '' ? f.type === selectedType : true) &&
@@ -681,7 +681,8 @@
}
toast.success($i18n.t('Functions imported successfully'));
functions.set(await getFunctions(localStorage.token));
functions = await getFunctionList(localStorage.token);
_functions.set(await getFunctions(localStorage.token));
models.set(
await getModels(
localStorage.token,
@@ -690,6 +691,8 @@
true
)
);
importFiles = null;
functionsImportInputElement.value = '';
};
reader.readAsText(importFiles[0]);

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