Compare commits

...
148 Commits
Author SHA1 Message Date
Tim Baek e4e69a10ec Merge pull request #22469 from open-webui/dev
0.8.10
2026-03-09 04:09:43 +04:00
Timothy Jaeryang Baek c6a1469fad refac 2026-03-08 19:05:15 -05:00
Timothy Jaeryang Baek 97cc94756e chore: bump 2026-03-08 19:00:50 -05:00
Classic298 cb73257f14 chore: Changelog updates (#22460)
* Add v0.8.10 changelog entry

* changelog: docker startup fix for missing opentelemetry dependency

* changelog: add translation updates for v0.8.10

* changelog: oauth-error-handling, exception-messages

* changelog: fix YAML file processing with Docling (#22399)

* changelog: tool access fix for non-admin users

* changelog: fix time range month names localization

* changelog: pipeline filter, webhook crash, shutdown handling

* changelog: tool method filtering, OAuth URL encoding, Azure TTS

* changelog: add analytics group filtering fix

* changelog: api-calls, optimization, performance

* changelog: add MariaDB Vector support entry

* changelog: add web search favicon fallback fix

* changelog: custom model fallback fix

* changelog: pending message image display fix (#22256)

* changelog: task message truncation for title and tag generation

* changelog: oidc, logout, custom-endpoint

* changelog: files list stability fix for issue #21879

* Remove empty Changed section from v0.8.10

* changelog: file metadata sanitization fix

* Remove empty Changed section from v0.8.10

* changelog: fix knowledge file embedding updates for RAG

* changelog: add Azure speech transcription error fix
2026-03-08 18:58:01 -05:00
Timothy Jaeryang Baek 61366cbcda refac 2026-03-08 18:57:20 -05:00
Timothy Jaeryang Baek c3e1d2d894 refac 2026-03-08 18:40:47 -05:00
Timothy Jaeryang Baek 0bfacca0a0 refac 2026-03-08 18:30:16 -05:00
Timothy Jaeryang Baek 1364df0913 refac 2026-03-08 18:26:36 -05:00
Timothy Jaeryang Baek 352391fa76 chore: format 2026-03-08 18:14:09 -05:00
Timothy Jaeryang Baek 2cb28369b7 refac 2026-03-08 18:08:12 -05:00
Timothy Jaeryang Baek 3f350f8659 refac 2026-03-08 17:52:49 -05:00
Timothy Jaeryang Baek 9d8f590fc5 feat: support pipe filters for per-message content truncation in task templates
Add |middletruncate:n, |start:n, and |end:n pipe filters to the
{{MESSAGES}} template variable, enabling per-message character
truncation for task models (title, tags, follow-up, etc.).

Example: {{MESSAGES:END:2|middletruncate:500}}

This optimizes task model prompt size for conversations with very
long messages (e.g. pasted documents), reducing latency for local
models and API costs.

Closes #21499
2026-03-08 17:43:23 -05:00
Timothy Jaeryang Baek defeddf21b fix: display image thumbnails in pending message queue
Previously, QueuedMessageItem only rendered text content and ignored
the files array, causing queued messages with only images to appear
blank. Now passes files to the component and renders image thumbnails
and file name indicators inline.

Fixes #22256
2026-03-08 17:28:26 -05:00
Timothy Jaeryang Baek c97767424f refac/fix 2026-03-08 17:26:29 -05:00
fama-05 d513eb8c4d Update Citations.svelte (#21897)
Add on:error handler to img
2026-03-08 17:14:43 -05:00
Code with love 265d1b2824 Add support for mariadb-vector as backing vector DB (#21931) 2026-03-08 17:13:14 -05:00
Classic298 caf3362be8 fix: add missing group_id filter to analytics token usage query (#22167)
The get_token_usage_by_user query lacked group_id filtering, while the
companion get_message_count_by_user query already supported it. When an
admin filtered analytics by user group, message counts were correctly
scoped to the group but token usage totals included data from all users.

Add the group_id parameter and subquery filter to get_token_usage_by_user,
matching the pattern used by get_message_count_by_user and other analytics
queries, and pass group_id through from the analytics endpoint.
2026-03-08 17:11:12 -05:00
Timothy Jaeryang Baek 3e952044bd refac 2026-03-08 17:09:41 -05:00
Alvin Tangandgambletan 7aa7bbc390 fix: correct Azure TTS locale extraction for SSML xml:lang (#22443)
The locale for Azure TTS SSML was being extracted with `split("-")[:1]`,
which only takes the first segment (e.g., "en" from "en-US"). The
xml:lang attribute in SSML requires a full locale like "en-US", not just
a language code. This caused Azure TTS to either fail or use incorrect
pronunciation rules.

Changed `[:1]` to `[:2]` to properly extract the locale (e.g., "en-US").

Co-authored-by: gambletan <ethanchang32@gmail.com>
2026-03-08 16:50:25 -05:00
Ethan T.andgambletan a97f5adf95 fix: URL-encode OAuth error message in redirect URL (#22415)
- URL-encodes the OAuth error message when constructing the redirect URL in the OIDC callback handler
- Without encoding, error messages containing spaces, ampersands, or other special characters produce malformed URLs that the frontend cannot parse correctly
- The custom OAuth client callback handler already correctly uses urllib.parse.quote_plus() for the same purpose; this fix brings the OIDC handler in line with that pattern

Co-authored-by: gambletan <tan@gambletan.com>
2026-03-08 16:47:47 -05:00
Fu-Jie 138c4cbfcf fix: filter out internal tool methods starting with underscore (#22408) 2026-03-08 16:46:56 -05:00
Ethan T. 7a3c5c0f8a fix: replace bare except with except Exception in main.py (#22423) 2026-03-08 16:45:36 -05:00
Alvin Tangandgambletan 3e513be963 fix: prevent TypeError in Teams webhook when user data is missing (#22444)
json.loads(event_data.get("user", {})) crashes with TypeError when
the "user" key is absent because the default value {} is a dict, not
a JSON string. json.loads expects str/bytes, not dict.

Also handle the case where "user" is already a dict (not serialized
JSON) to make the webhook more robust.

Co-authored-by: gambletan <ethanchang32@gmail.com>
2026-03-08 16:45:21 -05:00
Alvin Tangandgambletan f78b238b40 fix: prevent pipeline filter from corrupting payload on HTTP error (#22445)
In both inlet and outlet filter processing, response.json() was called
BEFORE response.raise_for_status(). When a filter endpoint returns an
HTTP error, the user's chat payload gets silently overwritten with the
error response body. If the error is not caught, the corrupted payload
propagates through subsequent filters and into the chat completion.

Swapped the order so raise_for_status() runs first — payload is only
updated on success.

Co-authored-by: gambletan <ethanchang32@gmail.com>
2026-03-08 16:44:35 -05:00
Classic298 bbbe2b66b4 fix: use static month names in getTimeRange to prevent OS locale leaking into sidebar (#22454)
getTimeRange returns month names that are used as i18n translation keys
(consumed via \.t(chat.time_range) in the sidebar, search modal, etc.).
The keys must be exact English strings like 'January', 'February', etc.

Previously, toLocaleString('default', { month: 'long' }) was used to
generate these keys. The 'default' locale defers to the browser's locale
resolution, which in Firefox with intl.regional_prefs.use_os_locales=true
picks up OS regional settings instead of the browser language. This caused
German month names (e.g. 'Februar', 'Januar') to appear in the sidebar for
users whose OS region is set to Germany, even when both browser and app
language are set to English. Chrome was unaffected because it ignores OS
regional settings for the 'default' locale.

Since i18n has no translation key for 'Februar', the German string passed
through untranslated. Replace toLocaleString with a static MONTH_NAMES
array lookup to make the intent explicit and eliminate any browser/OS
locale dependency.
2026-03-08 16:44:03 -05:00
Classic298 63a0befd3c fix: add missing opentelemetry-instrumentation-system-metrics dependency (#22447) 2026-03-08 16:41:42 -05:00
Timothy Jaeryang Baek 710320601a refac 2026-03-08 16:41:21 -05:00
Timothy Jaeryang Baek 67e26fd3af refac 2026-03-08 16:40:20 -05:00
2c35bdbcf5 fix: replace bare string raises with proper exception types (#22446)
`raise "string"` in Python raises TypeError instead of the intended
error, making error messages confusing and debugging difficult.

Co-authored-by: gambletan <ethanchang32@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:39:09 -05:00
amirsubhi abe865f4f3 i18n : Updated ms-MY Malay (Bahasa Malaysia) Language (#22450)
Update ms-MY Malay (Bahasa Malaysia) Language
2026-03-08 16:38:35 -05:00
joaoback e1d04be897 i18n: (pt-BR) add translations for newly added UI items + consistency pass (#22452)
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-08 16:38:24 -05:00
joaoback f419830a52 # i18n: (pt-BR) add translations for newly added UI items + consistency pass (#22391)
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-08 16:37:33 -05:00
Ethan T. 61bbb99d9e fix: replace bare except with Exception in oauth.py (#22420)
* fix: replace bare except with except Exception in main.py

* fix: replace bare except with Exception in oauth.py

In Python 3, bare 'except:' is discouraged as it catches all
SystemExit and KeyboardInterrupt exceptions. Changed to 'except Exception:'
to only catch actual exceptions.
2026-03-08 16:36:53 -05:00
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
175 changed files with 10319 additions and 3341 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
+112
View File
@@ -5,6 +5,118 @@ 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.10] - 2026-03-08
### Added
- 🔐 **Custom OIDC logout endpoint.** Administrators can now configure a custom OpenID Connect logout URL via OPENID_END_SESSION_ENDPOINT, enabling logout functionality for OIDC providers that require custom endpoints like AWS Cognito. [Commit](https://github.com/open-webui/open-webui/commit/3f350f865920daf2844769a758b2d2e6a7ee3efa)
- 🗄️ **MariaDB Vector community support.** Added MariaDB Vector as a new vector database backend, enabling deployments with VECTOR_DB=mariadb-vector; supports cosine and euclidean distance strategies with configurable HNSW indexing. [#21931](https://github.com/open-webui/open-webui/pull/21931)
- 📝 **Task message truncation.** Chat messages sent to task models for title and tag generation can now be truncated using a filter in the prompt template, reducing token usage and processing time for long conversations. [#21499](https://github.com/open-webui/open-webui/issues/21499)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Portuguese (Brazil), Spanish, and Malay were enhanced and expanded.
### Fixed
- 🔗 **Pipeline filter HTTP errors.** Fixed a bug where HTTP errors in pipeline inlet/outlet filters would silently corrupt the user's chat payload; errors are now properly raised before parsing the response. [#22445](https://github.com/open-webui/open-webui/pull/22445)
- 📚 **Knowledge file embedding updates.** Fixed a bug where updating knowledge files left old embeddings in the database, causing search results to include duplicate and stale data. [#20558](https://github.com/open-webui/open-webui/issues/20558)
- 📁 **Files list stability.** Fixed the files list ordering to use created_at with id as secondary sort, ensuring consistent ordering and preventing page crashes when managing many files. [#21879](https://github.com/open-webui/open-webui/issues/21879)
- 📨 **Teams webhook crash.** Fixed a TypeError crash in the Teams webhook handler when user data is missing from the event payload. [#22444](https://github.com/open-webui/open-webui/pull/22444)
- 🛠️ **Process shutdown handling.** Fixed bare except clauses in the main process that prevented clean shutdown; replaced with proper exception handling. [#22423](https://github.com/open-webui/open-webui/pull/22423)
- 🐳 **Docker deployment startup.** Docker deployments now start correctly; the missing OpenTelemetry system metrics dependency was added. [#22447](https://github.com/open-webui/open-webui/pull/22447), [#22401](https://github.com/open-webui/open-webui/issues/22401)
- 🛠️ **Tool access for non-admin users.** Fixed a NameError that prevented non-admin users from viewing tools; the missing has_access function is now properly imported. [#22393](https://github.com/open-webui/open-webui/issues/22393)
- 🔐 **OAuth error handling.** Fixed a bug where bare except clauses silently caught SystemExit and KeyboardInterrupt, preventing clean process shutdown during OAuth authentication. [#22420](https://github.com/open-webui/open-webui/pull/22420)
- 🛠️ **Exception error messages.** Fixed three locations where incorrect exception raising caused confusing TypeError messages instead of proper error descriptions, making debugging much easier. [#22446](https://github.com/open-webui/open-webui/pull/22446)
- 📄 **YAML file processing.** Fixed an error when uploading YAML files with Docling enabled; YAML and YML files are now properly recognized as text files and processed correctly. [#22399](https://github.com/open-webui/open-webui/pull/22399), [#22263](https://github.com/open-webui/open-webui/issues/22263)
- 📅 **Time range month names.** Fixed month names in time range labels appearing in the wrong language when OS regional settings differ from browser language; month names now consistently display in English. [#22454](https://github.com/open-webui/open-webui/pull/22454)
- 🔐 **OAuth error URL encoding.** Fixed OAuth error messages with special characters causing malformed redirect URLs; error messages are now properly URL-encoded. [#22415](https://github.com/open-webui/open-webui/pull/22415)
- 🛠️ **Internal tool method filtering.** Tools no longer expose internal methods starting with underscore to the LLM, reducing clutter and improving accuracy. [#22408](https://github.com/open-webui/open-webui/pull/22408)
- 🔊 **Azure TTS locale extraction.** Fixed Azure text-to-speech using incomplete locale codes in SSML; now correctly uses full locale like "en-US" instead of just "en". [#22443](https://github.com/open-webui/open-webui/pull/22443)
- 🎤 **Azure speech transcription errors.** Improved Azure AI Speech error handling to display user-friendly messages instead of generic connection errors; empty transcripts, no language identified, and other Azure-specific errors now show clear descriptions. [#20485](https://github.com/open-webui/open-webui/issues/20485)
- 📊 **Analytics group filtering.** Fixed token usage analytics not being filtered by user group; the query now properly respects group filters like other analytics metrics. [#22167](https://github.com/open-webui/open-webui/pull/22167)
- 🔍 **Web search favicon fallback.** Fixed web search sources showing broken image icons when favicons couldn't be loaded from external sources; now falls back to the default Open WebUI favicon. [#21897](https://github.com/open-webui/open-webui/pull/21897)
- 🔄 **Custom model fallback.** Fixed custom model fallback not working when the base model is unavailable; the base model ID is now correctly retrieved from model info instead of empty params. [#22456](https://github.com/open-webui/open-webui/issues/22456)
- 🖼️ **Pending message image display.** Fixed images in queued messages appearing blank; image thumbnails are now properly displayed in the pending message queue. [#22256](https://github.com/open-webui/open-webui/issues/22256)
- 🛠️ **File metadata sanitization.** Fixed file uploads failing with JSON serialization errors when metadata contained non-serializable objects like callable functions; metadata is now sanitized before database insertion. [#20561](https://github.com/open-webui/open-webui/issues/20561)
## [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
+1
View File
@@ -127,6 +127,7 @@ RUN chown -R $UID:$GID /app $HOME
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc netcat-openbsd curl jq \
libmariadb-dev \
python3-dev \
ffmpeg libsm6 libxext6 zstd \
&& rm -rf /var/lib/apt/lists/*
+123 -15
View File
@@ -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",
@@ -463,6 +469,12 @@ OPENID_PROVIDER_URL = PersistentConfig(
os.environ.get("OPENID_PROVIDER_URL", ""),
)
OPENID_END_SESSION_ENDPOINT = PersistentConfig(
"OPENID_END_SESSION_ENDPOINT",
"oauth.oidc.end_session_endpoint",
os.environ.get("OPENID_END_SESSION_ENDPOINT", ""),
)
OPENID_REDIRECT_URI = PersistentConfig(
"OPENID_REDIRECT_URI",
"oauth.oidc.redirect_uri",
@@ -838,13 +850,18 @@ def load_oauth_providers():
if FEISHU_CLIENT_ID.value:
configured_providers.append("Feishu")
if configured_providers and not OPENID_PROVIDER_URL.value:
if (
configured_providers
and not OPENID_PROVIDER_URL.value
and not OPENID_END_SESSION_ENDPOINT.value
):
provider_list = ", ".join(configured_providers)
log.warning(
f"⚠️ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!"
)
log.warning(
f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint to fix logout functionality."
f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint,"
f" or set OPENID_END_SESSION_ENDPOINT to a custom logout URL to fix logout functionality."
)
@@ -1960,7 +1977,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:
@@ -2290,20 +2307,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."""
####################################
@@ -2337,6 +2370,81 @@ if VECTOR_DB == "chroma":
CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
# MariaDB Vector (mariadb-vector)
MARIADB_VECTOR_DB_URL = os.environ.get("MARIADB_VECTOR_DB_URL", "").strip()
MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536").strip()
or "1536"
)
# Distance strategy:
# - cosine => vec_distance_cosine(...)
# - euclidean => vec_distance_euclidean(...)
MARIADB_VECTOR_DISTANCE_STRATEGY = (
os.environ.get("MARIADB_VECTOR_DISTANCE_STRATEGY", "cosine").strip().lower()
)
# HNSW M parameter (MariaDB VECTOR INDEX ... M=<int>)
MARIADB_VECTOR_INDEX_M = int(
os.environ.get("MARIADB_VECTOR_INDEX_M", "8").strip() or "8"
)
# Pooling (MariaDB-Vector)
MARIADB_VECTOR_POOL_SIZE = os.environ.get("MARIADB_VECTOR_POOL_SIZE", None)
if MARIADB_VECTOR_POOL_SIZE != None:
try:
MARIADB_VECTOR_POOL_SIZE = int(MARIADB_VECTOR_POOL_SIZE)
except Exception:
MARIADB_VECTOR_POOL_SIZE = None
MARIADB_VECTOR_POOL_MAX_OVERFLOW = os.environ.get("MARIADB_VECTOR_POOL_MAX_OVERFLOW", 0)
if MARIADB_VECTOR_POOL_MAX_OVERFLOW == "":
MARIADB_VECTOR_POOL_MAX_OVERFLOW = 0
else:
try:
MARIADB_VECTOR_POOL_MAX_OVERFLOW = int(MARIADB_VECTOR_POOL_MAX_OVERFLOW)
except Exception:
MARIADB_VECTOR_POOL_MAX_OVERFLOW = 0
MARIADB_VECTOR_POOL_TIMEOUT = os.environ.get("MARIADB_VECTOR_POOL_TIMEOUT", 30)
if MARIADB_VECTOR_POOL_TIMEOUT == "":
MARIADB_VECTOR_POOL_TIMEOUT = 30
else:
try:
MARIADB_VECTOR_POOL_TIMEOUT = int(MARIADB_VECTOR_POOL_TIMEOUT)
except Exception:
MARIADB_VECTOR_POOL_TIMEOUT = 30
MARIADB_VECTOR_POOL_RECYCLE = os.environ.get("MARIADB_VECTOR_POOL_RECYCLE", 3600)
if MARIADB_VECTOR_POOL_RECYCLE == "":
MARIADB_VECTOR_POOL_RECYCLE = 3600
else:
try:
MARIADB_VECTOR_POOL_RECYCLE = int(MARIADB_VECTOR_POOL_RECYCLE)
except Exception:
MARIADB_VECTOR_POOL_RECYCLE = 3600
ENABLE_MARIADB_VECTOR = True
if VECTOR_DB == "mariadb-vector":
if not MARIADB_VECTOR_DB_URL:
ENABLE_MARIADB_VECTOR = False
else:
try:
parsed = urlparse(MARIADB_VECTOR_DB_URL)
scheme = (parsed.scheme or "").lower()
# Require official driver so VECTOR binds as float32 bytes correctly
if scheme != "mariadb+mariadbconnector":
ENABLE_MARIADB_VECTOR = False
except Exception:
ENABLE_MARIADB_VECTOR = False
# Milvus
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
+10
View File
@@ -788,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"
+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")
+44 -37
View File
@@ -1398,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)
@@ -1726,8 +1732,8 @@ async def chat_completion(
}
# Check base model existence for custom models
if model_info_params.get("base_model_id"):
base_model_id = model_info_params.get("base_model_id")
if model_info and model_info.base_model_id:
base_model_id = model_info.base_model_id
if base_model_id not in request.app.state.MODELS:
if ENABLE_CUSTOM_MODEL_FALLBACK:
default_models = (
@@ -1858,7 +1864,7 @@ async def chat_completion(
"model": model_id,
},
)
except:
except Exception:
pass
ctx = build_chat_response_context(
@@ -1905,7 +1911,7 @@ async def chat_completion(
{"type": "chat:tasks:cancel"},
)
except:
except Exception:
pass
finally:
try:
@@ -2187,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": {
@@ -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]
@@ -139,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)"
)
+12 -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()
@@ -418,11 +420,13 @@ class ChatMessageTable:
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by user using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
from open_webui.models.groups import GroupMember
dialect = db.bind.dialect.name
@@ -462,6 +466,13 @@ class ChatMessageTable:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.user_id).all()
+20 -16
View File
@@ -734,13 +734,13 @@ 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
@@ -793,13 +793,13 @@ 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
@@ -854,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)
@@ -892,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
)
@@ -1039,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()
@@ -1188,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
@@ -1309,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)
+11 -3
View File
@@ -4,6 +4,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.utils.misc import sanitize_metadata
from pydantic import BaseModel, ConfigDict, model_validator
from sqlalchemy import BigInteger, Column, String, Text, JSON
@@ -127,9 +128,16 @@ class FilesTable:
self, user_id: str, form_data: FileForm, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
file_data = form_data.model_dump()
# Sanitize meta to remove non-JSON-serializable objects
# (e.g. callable tool functions, MCP client instances from middleware)
if file_data.get("meta"):
file_data["meta"] = sanitize_metadata(file_data["meta"])
file = FileModel(
**{
**form_data.model_dump(),
**file_data,
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
@@ -289,7 +297,7 @@ class FilesTable:
db: Optional database session.
Returns:
List of matching FileModel objects, ordered by updated_at descending.
List of matching FileModel objects, ordered by created_at descending.
"""
with get_db_context(db) as db:
query = db.query(File)
@@ -303,7 +311,7 @@ class FilesTable:
return [
FileModel.model_validate(file)
for file in query.order_by(File.updated_at.desc())
for file in query.order_by(File.created_at.desc(), File.id.desc())
.offset(skip)
.limit(limit)
.all()
+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]:
@@ -86,6 +86,9 @@ known_source_ext = [
"hs",
"lhs",
"json",
"yaml",
"yml",
"toml",
]
+6 -2
View File
@@ -590,7 +590,9 @@ def generate_openai_batch_embeddings(
if "data" in data:
return [elem["embedding"] for elem in data["data"]]
else:
raise "Something went wrong :/"
raise ValueError(
"Unexpected OpenAI embeddings response: missing 'data' key"
)
except Exception as e:
log.exception(f"Error generating openai batch embeddings: {e}")
return None
@@ -767,7 +769,9 @@ def generate_ollama_batch_embeddings(
if "embeddings" in data:
return data["embeddings"]
else:
raise "Something went wrong :/"
raise ValueError(
"Unexpected Ollama embeddings response: missing 'embeddings' key"
)
except Exception as e:
log.exception(f"Error generating ollama batch embeddings: {e}")
return None
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from elasticsearch import Elasticsearch, BadRequestError
from typing import Optional
import ssl
@@ -0,0 +1,593 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from __future__ import annotations
import array
import json
import logging
import math
import re
import sys
from contextlib import contextmanager
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool, QueuePool
from open_webui.config import (
MARIADB_VECTOR_DB_URL,
MARIADB_VECTOR_DISTANCE_STRATEGY,
MARIADB_VECTOR_INDEX_M,
MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
MARIADB_VECTOR_POOL_SIZE,
MARIADB_VECTOR_POOL_MAX_OVERFLOW,
MARIADB_VECTOR_POOL_TIMEOUT,
MARIADB_VECTOR_POOL_RECYCLE,
)
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
from open_webui.retrieval.vector.utils import process_metadata
log = logging.getLogger(__name__)
VECTOR_LENGTH = int(MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH)
def _embedding_to_f32_bytes(vec: List[float]) -> bytes:
"""
Convert a Python float vector into the binary payload expected by MariaDB VECTOR.
MariaDB Vector expects the vector argument to be bound as a little-endian float32
byte sequence. We use array('f') to avoid a numpy dependency and byteswap on
big-endian platforms for portability.
"""
a = array.array("f", [float(x) for x in vec]) # float32
if sys.byteorder != "little":
a.byteswap()
return a.tobytes()
def _safe_json(v: Any) -> Dict[str, Any]:
"""
Normalize a potentially JSON-like value into a Python dict.
Accepts:
- dict: returned as-is
- str / bytes: parsed as JSON if possible
- None / other types: returns {}
"""
if v is None:
return {}
if isinstance(v, dict):
return v
if isinstance(v, (bytes, bytearray)):
try:
v = v.decode("utf-8")
except Exception:
return {}
if isinstance(v, str):
try:
j = json.loads(v)
return j if isinstance(j, dict) else {}
except Exception:
return {}
return {}
class MariaDBVectorClient(VectorDBBase):
"""
MariaDB + MariaDB Vector backend using DBAPI cursor parameter binding.
IMPORTANT:
- Intended for: mariadb+mariadbconnector://... (official MariaDB driver).
- Uses qmark ("?") params and binds vectors as float32 bytes.
- Uses binary binding for BOTH inserts/updates and distance computations.
"""
def __init__(
self,
db_url: Optional[str] = None,
vector_length: int = VECTOR_LENGTH,
distance_strategy: str = MARIADB_VECTOR_DISTANCE_STRATEGY,
index_m: int = MARIADB_VECTOR_INDEX_M,
) -> None:
"""
Initialize a MariaDB Vector-backed VectorDBBase implementation.
Validates URL scheme/driver requirements, ensures schema exists, and guards
against dimension mismatch with an existing VECTOR(n) column.
"""
self.db_url = (db_url or MARIADB_VECTOR_DB_URL).strip()
self.vector_length = int(vector_length)
self.distance_strategy = (distance_strategy or "cosine").strip().lower()
self.index_m = int(index_m)
if self.distance_strategy not in {"cosine", "euclidean"}:
raise ValueError("distance_strategy must be 'cosine' or 'euclidean'")
if not self.db_url.lower().startswith("mariadb+mariadbconnector://"):
raise ValueError(
"MariaDBVectorClient requires mariadb+mariadbconnector:// (official MariaDB driver) "
"to ensure qmark paramstyle and correct VECTOR binding."
)
if isinstance(MARIADB_VECTOR_POOL_SIZE, int):
if MARIADB_VECTOR_POOL_SIZE > 0:
self.engine = create_engine(
self.db_url,
pool_size=MARIADB_VECTOR_POOL_SIZE,
max_overflow=MARIADB_VECTOR_POOL_MAX_OVERFLOW,
pool_timeout=MARIADB_VECTOR_POOL_TIMEOUT,
pool_recycle=MARIADB_VECTOR_POOL_RECYCLE,
pool_pre_ping=True,
poolclass=QueuePool,
)
else:
self.engine = create_engine(
self.db_url, pool_pre_ping=True, poolclass=NullPool
)
else:
self.engine = create_engine(self.db_url, pool_pre_ping=True)
self._init_schema()
self._check_vector_length()
@contextmanager
def _connect(self):
"""
Yield a context-managed DBAPI connection (SQLAlchemy raw_connection()).
Callers can use:
with self._connect() as conn:
with conn.cursor() as cur:
...
"""
conn = self.engine.raw_connection()
try:
yield conn
finally:
try:
conn.close()
except Exception:
pass
def _init_schema(self) -> None:
"""
Create the backing table and vector index if they do not exist.
Uses a PK definition compatible with MariaDB Vector's VECTOR INDEX key-size constraints.
"""
with self._connect() as conn:
with conn.cursor() as cur:
try:
dist = self.distance_strategy
cur.execute(f"""
CREATE TABLE IF NOT EXISTS document_chunk (
-- MariaDB Vector requires the table PRIMARY KEY used with a VECTOR INDEX to be <= 256 bytes.
-- VARCHAR has internal length/metadata overhead, so VARCHAR(255) can exceed the 256-byte limit.
-- We use VARCHAR(254) to stay safely under the limit, and force ASCII (1 byte/char) so the byte
-- size is predictable (avoid utf8mb4 where a "255 char" key could be up to 1020 bytes).
-- ascii_bin gives bytewise, case-sensitive comparisons for stable ID matching.
id VARCHAR(254) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
embedding VECTOR({self.vector_length}) NOT NULL,
collection_name VARCHAR(255) NOT NULL,
text LONGTEXT NULL,
vmetadata JSON NULL,
VECTOR INDEX (embedding) M={self.index_m} DISTANCE={dist},
INDEX idx_document_chunk_collection_name (collection_name)
) ENGINE=InnoDB;
""")
conn.commit()
except Exception as e:
conn.rollback()
log.exception(f"Error during database initialization: {e}")
raise
def _check_vector_length(self) -> None:
"""
Validate that the existing VECTOR column dimension matches this client's configured dimension.
Dimension guard: if table already exists with
a different VECTOR(n), refuse to silently mismatch.
"""
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute("SHOW CREATE TABLE document_chunk")
row = cur.fetchone()
if not row or len(row) < 2:
return
ddl = row[1]
m = re.search(r"vector\\((\\d+)\\)", ddl, flags=re.IGNORECASE)
if not m:
return
existing = int(m.group(1))
if existing != int(self.vector_length):
raise Exception(
f"VECTOR_LENGTH {self.vector_length} does not match existing vector column dimension {existing}. "
"Cannot change vector size after initialization without migrating the data."
)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
"""
Pad or truncate a vector to match `self.vector_length`.
"""
n = len(vector)
if n < self.vector_length:
return vector + [0.0] * (self.vector_length - n)
if n > self.vector_length:
return vector[: self.vector_length]
return vector
def _dist_fn(self) -> str:
"""
Return the MariaDB Vector distance function name for the configured strategy.
"""
return (
"vec_distance_cosine"
if self.distance_strategy == "cosine"
else "vec_distance_euclidean"
)
def _score_from_dist(self, dist: float) -> float:
"""
Convert a DB distance value into a normalized score in (0, 1].
- cosine: score ~= 1 - cosine_distance, clamped to [0, 1]
- euclidean: score = 1 / (1 + dist)
"""
if self.distance_strategy == "cosine":
score = 1.0 - dist
if score < 0.0:
score = 0.0
if score > 1.0:
score = 1.0
return score
return 1.0 / (1.0 + max(0.0, dist))
def _build_filter_sql_qmark(self, expr: Any) -> Tuple[str, List[Any]]:
"""
Build a WHERE-clause fragment and qmark params from a minimal Mongo-like filter.
Supported forms:
- {"field": "v"}
- {"field": {"$in": ["a","b"]}}
- {"$and": [ ... ]}
- {"$or": [ ... ]}
"""
if not expr or not isinstance(expr, dict):
return "", []
if "$and" in expr:
parts: List[str] = []
params: List[Any] = []
for e in expr.get("$and") or []:
s, p = self._build_filter_sql_qmark(e)
if s:
parts.append(s)
params.extend(p)
return ("(" + " AND ".join(parts) + ")") if parts else "", params
if "$or" in expr:
parts: List[str] = []
params: List[Any] = []
for e in expr.get("$or") or []:
s, p = self._build_filter_sql_qmark(e)
if s:
parts.append(s)
params.extend(p)
return ("(" + " OR ".join(parts) + ")") if parts else "", params
clauses: List[str] = []
params: List[Any] = []
for key, value in expr.items():
if key.startswith("$"):
continue
json_expr = f"JSON_UNQUOTE(JSON_EXTRACT(vmetadata, '$.{key}'))"
if isinstance(value, dict) and "$in" in value:
vals = [str(v) for v in (value.get("$in") or [])]
if not vals:
clauses.append("0=1")
continue
ors = []
for v in vals:
ors.append(f"{json_expr} = ?")
params.append(v)
clauses.append("(" + " OR ".join(ors) + ")")
else:
clauses.append(f"{json_expr} = ?")
params.append(str(value))
return ("(" + " AND ".join(clauses) + ")") if clauses else "", params
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Insert items into the given collection (best-effort, ignores duplicates).
Uses executemany() with binary VECTOR binding for high-throughput ingestion.
"""
if not items:
return
with self._connect() as conn:
with conn.cursor() as cur:
try:
sql = """
INSERT IGNORE INTO document_chunk
(id, embedding, collection_name, text, vmetadata)
VALUES
(?, ?, ?, ?, ?)
"""
params: List[Tuple[Any, ...]] = []
for item in items:
v = self.adjust_vector_length(item["vector"])
emb = _embedding_to_f32_bytes(v)
meta = process_metadata(item.get("metadata") or {})
params.append(
(
item["id"],
emb,
collection_name,
item.get("text"),
json.dumps(meta),
)
)
cur.executemany(sql, params)
conn.commit()
except Exception as e:
conn.rollback()
log.exception(f"Error during insert: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""
Insert or update items in the given collection by primary key.
Uses executemany() and updates embedding/text/metadata on conflicts.
"""
if not items:
return
with self._connect() as conn:
with conn.cursor() as cur:
try:
sql = """
INSERT INTO document_chunk
(id, embedding, collection_name, text, vmetadata)
VALUES
(?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
embedding = VALUES(embedding),
collection_name = VALUES(collection_name),
text = VALUES(text),
vmetadata = VALUES(vmetadata)
"""
params: List[Tuple[Any, ...]] = []
for item in items:
v = self.adjust_vector_length(item["vector"])
emb = _embedding_to_f32_bytes(v)
meta = process_metadata(item.get("metadata") or {})
params.append(
(
item["id"],
emb,
collection_name,
item.get("text"),
json.dumps(meta),
)
)
cur.executemany(sql, params)
conn.commit()
except Exception as e:
conn.rollback()
log.exception(f"Error during upsert: {e}")
raise
def search(
self,
collection_name: str,
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""
Perform a vector similarity search.
Args:
collection_name: Logical collection partition key.
vectors: One or more query vectors.
filter: Optional metadata filter (Mongo-like subset).
limit: Top-k per query vector.
Returns a SearchResult where distances are normalized scores (higher is better).
"""
if not vectors:
return None
dist_fn = self._dist_fn()
ids: List[List[str]] = [[] for _ in vectors]
distances: List[List[float]] = [[] for _ in vectors]
documents: List[List[str]] = [[] for _ in vectors]
metadatas: List[List[Any]] = [[] for _ in vectors]
try:
with self._connect() as conn:
with conn.cursor() as cur:
fsql, fparams = self._build_filter_sql_qmark(filter or {})
where = "collection_name = ?"
base_params: List[Any] = [collection_name]
if fsql:
where = where + " AND " + fsql
base_params.extend(fparams)
sql = f"""
SELECT
id,
text,
vmetadata,
{dist_fn}(embedding, ?) AS dist
FROM document_chunk
WHERE {where}
ORDER BY dist ASC
LIMIT ?
"""
for q_idx, q in enumerate(vectors):
qv = self.adjust_vector_length(q)
qbin = _embedding_to_f32_bytes(qv)
params = [qbin] + list(base_params) + [int(limit)]
cur.execute(sql, params)
rows = cur.fetchall()
for r in rows:
rid, rtext, rmeta, rdist = r[0], r[1], r[2], r[3]
ids[q_idx].append(str(rid))
try:
dist = float(rdist) if rdist is not None else 1.0
except Exception:
dist = 1.0
if math.isnan(dist) or math.isinf(dist):
dist = 1.0
distances[q_idx].append(self._score_from_dist(dist))
documents[q_idx].append(rtext)
metadatas[q_idx].append(_safe_json(rmeta))
return SearchResult(
ids=ids,
distances=distances,
documents=documents,
metadatas=metadatas,
)
except Exception as e:
log.exception(f"[MARIADB_VECTOR] search() failed: {e}")
return None
def query(
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
) -> Optional[GetResult]:
"""
Retrieve documents by metadata filter (non-vector query).
"""
with self._connect() as conn:
with conn.cursor() as cur:
fsql, fparams = self._build_filter_sql_qmark(filter or {})
where = "collection_name = ?"
params: List[Any] = [collection_name]
if fsql:
where = where + " AND " + fsql
params.extend(fparams)
sql = f"SELECT id, text, vmetadata FROM document_chunk WHERE {where}"
if limit is not None:
sql += " LIMIT ?"
params.append(int(limit))
cur.execute(sql, params)
rows = cur.fetchall()
if not rows:
return None
ids = [[str(r[0]) for r in rows]]
documents = [[r[1] for r in rows]]
metadatas = [[_safe_json(r[2]) for r in rows]]
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
def get(
self, collection_name: str, limit: Optional[int] = None
) -> Optional[GetResult]:
"""
Retrieve documents in a collection without filtering (optionally limited).
"""
with self._connect() as conn:
with conn.cursor() as cur:
sql = "SELECT id, text, vmetadata FROM document_chunk WHERE collection_name = ?"
params: List[Any] = [collection_name]
if limit is not None:
sql += " LIMIT ?"
params.append(int(limit))
cur.execute(sql, params)
rows = cur.fetchall()
if not rows:
return None
ids = [[str(r[0]) for r in rows]]
documents = [[r[1] for r in rows]]
metadatas = [[_safe_json(r[2]) for r in rows]]
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
) -> None:
"""
Delete rows from a collection by id list and/or metadata filter.
If both are provided, they are combined with AND semantics.
"""
with self._connect() as conn:
with conn.cursor() as cur:
try:
where = ["collection_name = ?"]
params: List[Any] = [collection_name]
if ids:
ph = ", ".join(["?"] * len(ids))
where.append(f"id IN ({ph})")
params.extend(ids)
if filter:
fsql, fparams = self._build_filter_sql_qmark(filter)
if fsql:
where.append(fsql)
params.extend(fparams)
sql = "DELETE FROM document_chunk WHERE " + " AND ".join(where)
cur.execute(sql, params)
conn.commit()
except Exception as e:
conn.rollback()
log.exception(f"Error during delete: {e}")
raise
def reset(self) -> None:
"""
Truncate the vector table (drops all collections).
"""
with self._connect() as conn:
with conn.cursor() as cur:
try:
cur.execute("TRUNCATE TABLE document_chunk")
conn.commit()
except Exception as e:
conn.rollback()
log.exception(f"Error during reset: {e}")
raise
def has_collection(self, collection_name: str) -> bool:
"""
Return True if the collection contains at least one row, else False.
"""
try:
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM document_chunk WHERE collection_name = ? LIMIT 1",
(collection_name,),
)
return cur.fetchone() is not None
except Exception:
return False
def delete_collection(self, collection_name: str) -> None:
"""
Delete all rows in a collection.
"""
self.delete(collection_name)
def close(self) -> None:
"""
Dispose the underlying SQLAlchemy engine.
"""
try:
self.engine.dispose()
except Exception as e:
log.exception(f"Error during dispose the underlying SQLAlchemy engine: {e}")
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from pymilvus import MilvusClient as Client
from pymilvus import FieldSchema, DataType
from pymilvus import connections, Collection
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
import logging
from typing import Optional, Tuple, List, Dict, Any
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from typing import Optional, List, Dict, Any
import logging
import re
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
@@ -1,4 +1,6 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
Oracle 23ai Vector Database Client - Fixed Version
# .env
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from typing import Optional, List, Dict, Any, Union
import logging
import time # for measuring elapsed time
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from typing import Optional
import logging
from urllib.parse import urlparse
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
import logging
from typing import Optional, Tuple, List, Dict, Any
from urllib.parse import urlparse
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
@@ -61,6 +65,11 @@ class S3VectorClient(VectorDBBase):
dataType=data_type,
dimension=dimension,
distanceMetric=distance_metric,
metadataConfiguration={
"nonFilterableMetadataKeys": [
"text",
]
},
)
log.info(
f"Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})"
@@ -1,3 +1,7 @@
"""
NOTE: This vector database integration is community-supported and maintained on a best-effort basis.
"""
import weaviate
import re
import uuid
@@ -57,6 +57,12 @@ class Vector:
from open_webui.retrieval.vector.dbs.opengauss import OpenGaussClient
return OpenGaussClient()
case VectorType.MARIADB_VECTOR:
from open_webui.retrieval.vector.dbs.mariadb_vector import (
MariaDBVectorClient,
)
return MariaDBVectorClient()
case VectorType.ELASTICSEARCH:
from open_webui.retrieval.vector.dbs.elasticsearch import (
ElasticsearchClient,
@@ -3,6 +3,7 @@ from enum import StrEnum
class VectorType(StrEnum):
MILVUS = "milvus"
MARIADB_VECTOR = "mariadb-vector"
QDRANT = "qdrant"
CHROMA = "chroma"
PINECONE = "pinecone"
+3 -3
View File
@@ -86,7 +86,7 @@ async def get_user_analytics(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
token_usage = ChatMessages.get_token_usage_by_user(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
# Get user info for top users
@@ -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)"),
+26 -3
View File
@@ -492,7 +492,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
region = request.app.state.config.TTS_AZURE_SPEECH_REGION or "eastus"
base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL
language = request.app.state.config.TTS_VOICE
locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:1])
locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:2])
output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT
try:
@@ -852,17 +852,34 @@ def transcription_handler(request, file_path, metadata, user=None):
except requests.exceptions.RequestException as e:
log.exception(e)
detail = None
status_code = getattr(r, "status_code", 500) if r else 500
try:
if r is not None and r.status_code != 200:
res = r.json()
if "error" in res:
# Azure returns {"code": "...", "message": "...", "innerError": {...}}
if "code" in res and "message" in res:
azure_code = res.get("innerError", {}).get("code", res["code"])
user_facing_codes = {
"EmptyAudioFile",
"AudioLengthLimitExceeded",
"NoLanguageIdentified",
"MultipleLanguagesIdentified",
}
if azure_code in user_facing_codes:
detail = res["message"]
else:
log.error(
f"Azure STT error [{azure_code}]: {res['message']}"
)
detail = "An error occurred during transcription."
elif "error" in res:
detail = f"External: {res['error'].get('message', '')}"
except Exception:
detail = f"External: {e}"
raise HTTPException(
status_code=getattr(r, "status_code", 500) if r else 500,
status_code=status_code,
detail=detail if detail else "Open WebUI: Server Connection Error",
)
@@ -1087,6 +1104,8 @@ def transcribe(
for future in futures:
try:
results.append(future.result())
except HTTPException:
raise
except Exception as transcribe_exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -1226,6 +1245,8 @@ def transcription(
"filename": os.path.basename(file_path),
}
except HTTPException:
raise
except Exception as e:
log.exception(e)
@@ -1234,6 +1255,8 @@ def transcription(
detail="Transcription failed.",
)
except HTTPException:
raise
except Exception as e:
log.exception(e)
+14
View File
@@ -46,6 +46,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response, JSONResponse
from open_webui.config import (
OPENID_PROVIDER_URL,
OPENID_END_SESSION_ENDPOINT,
ENABLE_OAUTH_SIGNUP,
ENABLE_LDAP,
ENABLE_PASSWORD_AUTH,
@@ -824,6 +825,19 @@ async def signout(
response.delete_cookie("oauth_session_id")
session = OAuthSessions.get_session_by_id(oauth_session_id, db=db)
# If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect
# there directly instead of attempting OIDC discovery.
if OPENID_END_SESSION_ENDPOINT.value:
return JSONResponse(
status_code=200,
content={
"status": True,
"redirect_url": OPENID_END_SESSION_ENDPOINT.value,
},
headers=response.headers,
)
oauth_server_metadata_url = (
request.app.state.oauth_manager.get_server_metadata_url(session.provider)
if session
+24
View File
@@ -583,12 +583,36 @@ def update_file_data_content_by_id(
request,
ProcessFileForm(file_id=id, content=form_data.content),
user=user,
db=db,
)
file = Files.get_file_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file.id}")
# Propagate content change to all knowledge collections referencing
# this file. Without this the old embeddings remain in the knowledge
# collection and RAG returns both stale and current data (#20558).
knowledges = Knowledges.get_knowledges_by_file_id(id, db=db)
for knowledge in knowledges:
try:
# Remove old embeddings for this file from the KB collection
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": id}
)
# Re-add from the now-updated file-{file_id} collection
process_file(
request,
ProcessFileForm(file_id=id, collection_name=knowledge.id),
user=user,
db=db,
)
except Exception as e:
log.warning(
f"Failed to update knowledge {knowledge.id} after "
f"content change for file {id}: {e}"
)
return {"content": file.data.get("content", "")}
else:
raise HTTPException(
+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:
+4 -2
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
@@ -1747,7 +1747,9 @@ async def download_file_stream(
yield f"data: {json.dumps(res)}\n\n"
else:
raise "Ollama: Could not create blob, Please try again."
raise RuntimeError(
"Ollama: Could not create blob, Please try again."
)
# url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
+2 -2
View File
@@ -92,8 +92,8 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
json=request_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
payload = await response.json()
response.raise_for_status()
payload = await response.json()
except aiohttp.ClientResponseError as e:
res = (
await response.json()
@@ -145,8 +145,8 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
json=request_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
payload = await response.json()
response.raise_for_status()
payload = await response.json()
except aiohttp.ClientResponseError as e:
try:
res = (
+27 -24
View File
@@ -729,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
@@ -1055,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
+1 -1
View File
@@ -19,7 +19,7 @@ from open_webui.models.skills import (
)
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
+5 -1
View File
@@ -30,7 +30,11 @@ 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,
has_access,
filter_allowed_access_grants,
)
from open_webui.utils.tools import get_tool_servers
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
+2
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
@@ -918,6 +919,7 @@ def get_event_call(request_info):
"data": event_data,
},
to=request_info["session_id"],
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
)
return response
+10 -4
View File
@@ -155,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)
@@ -250,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:
@@ -315,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:
@@ -426,6 +429,9 @@ async def execute_code(
"session_id": (
__metadata__.get("session_id") if __metadata__ else None
),
"files": (
__metadata__.get("files", []) if __metadata__ else []
),
},
}
)
+235 -89
View File
@@ -1,3 +1,4 @@
import copy
import time
import logging
import sys
@@ -119,6 +120,7 @@ from open_webui.config import (
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
DEFAULT_CODE_INTERPRETER_PROMPT,
CODE_INTERPRETER_PYODIDE_PROMPT,
CODE_INTERPRETER_BLOCKED_MODULES,
)
from open_webui.env import (
@@ -160,6 +162,55 @@ def output_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex[:24]}"
def _split_tool_calls(
tool_calls: list[dict],
) -> list[dict]:
"""Expand tool calls whose arguments contain multiple back-to-back JSON objects.
Some models (e.g. GPT-5.4) send multiple complete JSON argument objects
under the same tool call index, producing concatenated invalid JSON like:
'{"query":"A","count":5}{"query":"B","count":5}'
Each such tool call is split into separate entries so each gets executed
independently. Single-object arguments pass through unchanged.
"""
def split_json_objects(raw: str) -> list[str]:
decoder = json.JSONDecoder()
results = []
position = 0
while position < len(raw):
while position < len(raw) and raw[position].isspace():
position += 1
if position >= len(raw):
break
try:
_, end = decoder.raw_decode(raw, position)
results.append(raw[position:end].strip())
position = end
except json.JSONDecodeError:
return [raw]
return results or [raw]
expanded = []
for tool_call in tool_calls:
arguments = tool_call.get("function", {}).get("arguments", "")
split_arguments = split_json_objects(arguments)
if len(split_arguments) <= 1:
expanded.append(tool_call)
else:
for argument in split_arguments:
cloned = copy.deepcopy(tool_call)
cloned["id"] = f"call_{uuid4().hex[:24]}"
cloned["function"]["arguments"] = argument
expanded.append(cloned)
return expanded
def get_citation_source_from_tool_result(
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ""
) -> list[dict]:
@@ -173,6 +224,9 @@ def get_citation_source_from_tool_result(
Returns a list of sources (usually one, but query_knowledge_files may return multiple).
"""
_EXPECTS_LIST = {"search_web", "query_knowledge_files"}
_EXPECTS_DICT = {"view_knowledge_file"}
try:
try:
tool_result = json.loads(tool_result)
@@ -181,6 +235,12 @@ def get_citation_source_from_tool_result(
if isinstance(tool_result, dict) and "error" in tool_result:
return []
# Validate tool_result type based on what the branch expects
if tool_name in _EXPECTS_LIST and not isinstance(tool_result, list):
return []
elif tool_name in _EXPECTS_DICT and not isinstance(tool_result, dict):
return []
if tool_name == "search_web":
# Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}]
results = tool_result
@@ -830,6 +890,32 @@ def handle_responses_streaming_event(
return current_output, None
def get_source_context(
sources: list, source_ids: dict = None, include_content: bool = True
) -> str:
"""
Build <source> tag context string from citation sources.
"""
context_string = ""
if source_ids is None:
source_ids = {}
for source in sources:
for doc, meta in zip(source.get("document", []), source.get("metadata", [])):
source_id = (
meta.get("source") or source.get("source", {}).get("id") or "N/A"
)
if source_id not in source_ids:
source_ids[source_id] = len(source_ids) + 1
src_name = source.get("source", {}).get("name")
body = doc if include_content else ""
context_string += (
f'<source id="{source_ids[source_id]}"'
+ (f' name="{src_name}"' if src_name else "")
+ f">{body}</source>\n"
)
return context_string
def apply_source_context_to_messages(
request: Request,
messages: list,
@@ -848,39 +934,21 @@ def apply_source_context_to_messages(
if not sources or not user_message:
return messages
context_string = ""
citation_idx = {}
context = get_source_context(sources, include_content=include_content)
for source in sources:
for doc, meta in zip(source.get("document", []), source.get("metadata", [])):
src_id = meta.get("source") or source.get("source", {}).get("id") or "N/A"
if src_id not in citation_idx:
citation_idx[src_id] = len(citation_idx) + 1
src_name = source.get("source", {}).get("name")
body = doc if include_content else ""
context_string += (
f'<source id="{citation_idx[src_id]}"'
+ (f' name="{src_name}"' if src_name else "")
+ f">{body}</source>\n"
)
context_string = context_string.strip()
if not context_string:
context = context.strip()
if not context:
return messages
if RAG_SYSTEM_CONTEXT:
return add_or_update_system_message(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, user_message
),
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
messages,
append=True,
)
else:
return add_or_update_user_message(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, user_message
),
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
messages,
append=False,
)
@@ -1053,16 +1121,16 @@ async def terminal_event_handler(
"""Emit terminal:* events for Open Terminal tools.
- display_file → emits 'terminal:display_file' to open the file preview.
- write_file → emits 'terminal:write_file' to silently refresh the file browser.
- write_file / replace_file_content → emits 'terminal:write_file' to refresh.
- run_command → emits 'terminal:run_command' with cwd to refresh if relevant.
"""
if not event_emitter:
return
path = tool_function_params.get("path", "")
if not path:
return
if tool_function_name == "display_file":
path = tool_function_params.get("path", "")
if not path:
return
# Only emit if the file actually exists
parsed = tool_result
if isinstance(parsed, str):
@@ -1079,13 +1147,23 @@ async def terminal_event_handler(
"data": {"path": path},
}
)
elif tool_function_name == "write_file":
elif tool_function_name in ("write_file", "replace_file_content"):
path = tool_function_params.get("path", "")
if not path:
return
await event_emitter(
{
"type": f"terminal:{tool_function_name}",
"data": {"path": path},
}
)
elif tool_function_name == "run_command":
await event_emitter(
{
"type": "terminal:run_command",
"data": {},
}
)
async def chat_completion_tools_handler(
@@ -2171,10 +2249,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
folder.data["system_prompt"], form_data, metadata, user
)
if "files" in folder.data:
form_data["files"] = [
*folder.data["files"],
*form_data.get("files", []),
]
if metadata.get("params", {}).get("function_calling") != "native":
form_data["files"] = [
*folder.data["files"],
*form_data.get("files", []),
]
else:
# Native FC: skip RAG injection, builtin tools
# will read folder knowledge from metadata.
metadata["folder_knowledge"] = folder.data["files"]
# Model "Knowledge" handling
user_message = get_last_user_message(form_data["messages"])
@@ -2284,18 +2367,35 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
if "code_interpreter" in features and features["code_interpreter"]:
engine = getattr(
request.app.state.config, "CODE_INTERPRETER_ENGINE", "pyodide"
)
# Skip XML-tag prompt injection when native FC is enabled —
# execute_code will be injected as a builtin tool instead
if metadata.get("params", {}).get("function_calling") != "native":
prompt = (
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != ""
else DEFAULT_CODE_INTERPRETER_PROMPT
)
# Append filesystem awareness only for pyodide engine
if engine != "jupyter":
prompt += CODE_INTERPRETER_PYODIDE_PROMPT
form_data["messages"] = add_or_update_user_message(
(
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
!= ""
else DEFAULT_CODE_INTERPRETER_PROMPT
),
prompt,
form_data["messages"],
)
else:
# Native FC: tool docstring can't be dynamic, so inject
# filesystem context into messages for pyodide engine
if engine != "jupyter":
form_data["messages"] = add_or_update_user_message(
CODE_INTERPRETER_PYODIDE_PROMPT,
form_data["messages"],
)
tool_ids = form_data.pop("tool_ids", None)
terminal_id = form_data.pop("terminal_id", None)
@@ -2645,6 +2745,16 @@ async def process_chat_payload(request, form_data, user, metadata, model):
except Exception as e:
log.exception(e)
# Save the pre-RAG message state so the native tool call loop can
# restore to the true original (before file-source injection) rather
# than a snapshot that already has the RAG template baked in.
system_message = get_system_message(form_data["messages"])
metadata["system_prompt"] = (
get_content_from_message(system_message) if system_message else None
)
metadata["user_prompt"] = get_last_user_message(form_data["messages"])
metadata["sources"] = sources[:] if sources else []
# If context is not empty, insert it into the messages
if sources and prompt:
form_data["messages"] = apply_source_context_to_messages(
@@ -3083,6 +3193,8 @@ async def non_streaming_chat_response_handler(response, ctx):
)
# Save message in the database
usage = normalize_usage(response_data.get("usage", {}) or {})
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
@@ -3090,6 +3202,7 @@ async def non_streaming_chat_response_handler(response, ctx):
"role": "assistant",
"content": content,
"output": response_output,
**({"usage": usage} if usage else {}),
},
)
@@ -3719,7 +3832,7 @@ async def streaming_chat_response_handler(response, ctx):
if delta_name:
current_response_tool_call[
"function"
]["name"] += delta_name
]["name"] = delta_name
if delta_arguments:
current_response_tool_call[
@@ -3767,14 +3880,17 @@ async def streaming_chat_response_handler(response, ctx):
delta.get("images", []), request, metadata, user
)
if image_urls:
image_file_list = [
{"type": "image", "url": url}
for url in image_urls
]
message_files = Chats.add_message_files_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
[
{"type": "image", "url": url}
for url in image_urls
],
image_file_list,
)
if message_files is None:
message_files = image_file_list
await event_emitter(
{
@@ -4079,7 +4195,7 @@ async def streaming_chat_response_handler(response, ctx):
reasoning_item["status"] = "completed"
if response_tool_calls:
tool_calls.append(response_tool_calls)
tool_calls.append(_split_tool_calls(response_tool_calls))
if response.background:
await response.background()
@@ -4096,16 +4212,17 @@ async def streaming_chat_response_handler(response, ctx):
model.get("info", {}).get("meta", {}).get("capabilities") or {}
).get("citations", True)
# Save original system message so we can restore it before
# re-applying source context (prevents duplication when
# RAG_SYSTEM_CONTEXT is enabled and the template is appended
# to the system message on each iteration).
original_system_message = get_system_message(form_data["messages"])
original_system_content = (
get_content_from_message(original_system_message)
if original_system_message
else None
)
# Use the pre-RAG system content captured before the
# initial file-source injection in process_chat_payload.
# This ensures restore truly undoes the RAG template.
original_system_content = metadata.get("system_prompt")
if original_system_content is None:
original_system_message = get_system_message(form_data["messages"])
original_system_content = (
get_content_from_message(original_system_message)
if original_system_message
else None
)
while (
len(tool_calls) > 0
@@ -4153,25 +4270,26 @@ async def streaming_chat_response_handler(response, ctx):
tool_args = tool_call.get("function", {}).get("arguments", "{}")
tool_function_params = {}
try:
# json.loads cannot be used because some models do not produce valid JSON
tool_function_params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
# Fallback to JSON parsing
if tool_args and tool_args.strip():
try:
tool_function_params = json.loads(tool_args)
# json.loads cannot be used because some models do not produce valid JSON
tool_function_params = ast.literal_eval(tool_args)
except Exception as e:
log.error(
f"Error parsing tool call arguments: {tool_args}"
)
results.append(
{
"tool_call_id": tool_call_id,
"content": f"Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.",
}
)
continue
log.debug(e)
# Fallback to JSON parsing
try:
tool_function_params = json.loads(tool_args)
except Exception as e:
log.error(
f"Error parsing tool call arguments: {tool_args}"
)
results.append(
{
"tool_call_id": tool_call_id,
"content": f"Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.",
}
)
continue
# Ensure arguments are valid JSON for downstream LLM integrations
log.debug(
@@ -4352,34 +4470,61 @@ async def streaming_chat_response_handler(response, ctx):
}
)
# Emit citation sources and apply source context to messages
# Emit citation sources to the frontend for display
if citations_enabled:
for source in tool_call_sources:
await event_emitter({"type": "source", "data": source})
# Apply source context to messages for model.
# Use include_content=False to avoid duplicating content
# that is already in the tool result message.
# Apply tool source context to messages for the model.
# Restoring to pre-RAG original prevents duplicating
# the RAG template across file and tool sources.
all_tool_call_sources.extend(tool_call_sources)
if all_tool_call_sources and user_message:
# Restore original messages before re-applying to
# avoid recursive nesting (user message) and
# duplication (system message with RAG_SYSTEM_CONTEXT).
set_last_user_message_content(
user_message, form_data["messages"]
# Restore pre-RAG message state before re-applying
# to prevent RAG template duplication.
original_user_message = (
metadata.get("user_prompt") or user_message
)
if original_system_content is not None:
replace_system_message_content(
original_system_content,
form_data["messages"],
)
form_data["messages"] = apply_source_context_to_messages(
request,
set_last_user_message_content(
original_user_message,
form_data["messages"],
)
replace_system_message_content(
original_system_content or "",
form_data["messages"],
)
# Build context: file sources with content,
# tool sources as citation markers only.
source_ids = {}
source_context = get_source_context(
metadata.get("sources", []), source_ids
) + get_source_context(
all_tool_call_sources,
user_message,
source_ids,
include_content=False,
)
source_context = source_context.strip()
if source_context:
rag_content = rag_template(
request.app.state.config.RAG_TEMPLATE,
source_context,
user_message,
)
if RAG_SYSTEM_CONTEXT:
form_data["messages"] = (
add_or_update_system_message(
rag_content,
form_data["messages"],
append=True,
)
)
else:
form_data["messages"] = add_or_update_user_message(
rag_content,
form_data["messages"],
append=False,
)
tool_call_sources.clear()
await event_emitter(
@@ -4482,6 +4627,7 @@ async def streaming_chat_response_handler(response, ctx):
"session_id": metadata.get(
"session_id", None
),
"files": metadata.get("files", []),
},
}
)
+47
View File
@@ -566,6 +566,53 @@ def sanitize_data_for_db(obj):
return obj
def sanitize_metadata(metadata: dict) -> dict:
"""
Return a JSON-safe copy of a metadata dict for database storage.
The middleware metadata accumulates non-serializable Python objects
(e.g. callable tool functions, MCP client instances) that cause
PostgreSQL JSON inserts to fail. This helper strips those out while
preserving the primitive data needed for file-to-chat linking.
"""
if not isinstance(metadata, dict):
return metadata
def _sanitize(obj):
if isinstance(obj, (str, int, float, bool, type(None))):
return obj
if isinstance(obj, dict):
return {
k: _sanitize(v)
for k, v in obj.items()
if not callable(v) and _is_serializable(v)
}
if isinstance(obj, list):
return [
_sanitize(v) for v in obj if not callable(v) and _is_serializable(v)
]
if callable(obj):
return None
# Last resort: try to see if it's serializable
try:
json.dumps(obj)
return obj
except (TypeError, ValueError):
return None
def _is_serializable(obj):
"""Quick check whether a value can survive JSON serialization."""
if isinstance(obj, (str, int, float, bool, type(None), dict, list)):
return True
try:
json.dumps(obj)
return True
except (TypeError, ValueError):
return False
return _sanitize(metadata)
def extract_folders_after_data_docs(path):
# Convert the path to a Path object if it's not already
path = Path(path)
+52 -47
View File
@@ -149,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}",
@@ -331,11 +332,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
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 = Functions.get_function_valves_by_id(action_id)
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:
+26 -2
View File
@@ -36,6 +36,7 @@ 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,
@@ -113,6 +114,9 @@ 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
@@ -616,7 +620,7 @@ class OAuthClientManager:
payload = json.loads(response_text)
error = payload.get("error")
error_description = payload.get("error_description", "")
except:
except Exception:
pass
else:
error_description = response_text
@@ -787,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(
@@ -1081,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(
@@ -1684,7 +1706,9 @@ class OAuthManager:
redirect_url = f"{redirect_base_url}/auth"
if error_message:
redirect_url = f"{redirect_url}?error={error_message}"
redirect_url = (
f"{redirect_url}?error={urllib.parse.quote_plus(error_message)}"
)
return RedirectResponse(url=redirect_url, headers=response.headers)
response = RedirectResponse(url=redirect_url, headers=response.headers)
+101 -21
View File
@@ -142,41 +142,121 @@ def replace_prompt_variable(template: str, prompt: str) -> str:
return template
def truncate_content(content: str, max_chars: int, mode: str = "middletruncate") -> str:
"""Truncate a string to max_chars using the specified mode.
Modes:
- middletruncate: keep beginning and end, join with '...'
- start: keep first max_chars characters
- end: keep last max_chars characters
"""
if not content or len(content) <= max_chars:
return content
if mode == "start":
return content[:max_chars]
elif mode == "end":
return content[-max_chars:]
else: # middletruncate
half = max_chars // 2
return f"{content[:half]}...{content[-(max_chars - half):]}"
def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]:
"""Apply a content filter to each message's content.
filter_str is like 'middletruncate:500', 'start:200', or 'end:200'.
Returns a new list with truncated content (original messages are not mutated).
"""
parts = filter_str.split(":")
if len(parts) != 2:
return messages
mode = parts[0].lower()
try:
max_chars = int(parts[1])
except ValueError:
return messages
if mode not in ("middletruncate", "start", "end"):
return messages
result = []
for msg in messages:
new_msg = dict(msg)
if isinstance(new_msg.get("content"), str):
new_msg["content"] = truncate_content(new_msg["content"], max_chars, mode)
elif isinstance(new_msg.get("content"), list):
new_content = []
for item in new_msg["content"]:
if isinstance(item, dict) and item.get("type") == "text":
new_item = dict(item)
new_item["text"] = truncate_content(
item.get("text", ""), max_chars, mode
)
new_content.append(new_item)
else:
new_content.append(item)
new_msg["content"] = new_content
result.append(new_msg)
return result
def replace_messages_variable(
template: str, messages: Optional[list[dict]] = None
) -> str:
def replacement_function(match):
full_match = match.group(0)
start_length = match.group(1)
end_length = match.group(2)
middle_length = match.group(3)
# Groups: (1) filter for bare MESSAGES
# (2) START count, (3) filter for START
# (4) END count, (5) filter for END
# (6) MIDDLE count,(7) filter for MIDDLE
bare_filter = match.group(1)
start_length = match.group(2)
start_filter = match.group(3)
end_length = match.group(4)
end_filter = match.group(5)
middle_length = match.group(6)
middle_filter = match.group(7)
# If messages is None, handle it as an empty list
if messages is None:
return ""
# Process messages based on the number of messages required
if full_match == "{{MESSAGES}}":
return get_messages_content(messages)
elif start_length is not None:
return get_messages_content(messages[: int(start_length)])
# Select messages based on the variant
if start_length is not None:
selected = messages[: int(start_length)]
content_filter = start_filter
elif end_length is not None:
return get_messages_content(messages[-int(end_length) :])
selected = messages[-int(end_length) :]
content_filter = end_filter
elif middle_length is not None:
mid = int(middle_length)
if len(messages) <= mid:
return get_messages_content(messages)
# Handle middle truncation: split to get start and end portions of the messages list
half = mid // 2
start_msgs = messages[:half]
end_msgs = messages[-half:] if mid % 2 == 0 else messages[-(half + 1) :]
formatted_start = get_messages_content(start_msgs)
formatted_end = get_messages_content(end_msgs)
return f"{formatted_start}\n{formatted_end}"
return ""
selected = messages
else:
half = mid // 2
start_msgs = messages[:half]
end_msgs = messages[-half:] if mid % 2 == 0 else messages[-(half + 1) :]
selected = start_msgs + end_msgs
content_filter = middle_filter
else:
# Bare {{MESSAGES}} or {{MESSAGES|filter}}
selected = messages
content_filter = bare_filter
# Apply content filter if present
if content_filter:
selected = apply_content_filter(selected, content_filter)
return get_messages_content(selected)
template = re.sub(
r"{{MESSAGES}}|{{MESSAGES:START:(\d+)}}|{{MESSAGES:END:(\d+)}}|{{MESSAGES:MIDDLETRUNCATE:(\d+)}}",
r"(?:"
r"\{\{MESSAGES(?:\|(\w+:\d+))?\}\}"
r"|\{\{MESSAGES:START:(\d+)(?:\|(\w+:\d+))?\}\}"
r"|\{\{MESSAGES:END:(\d+)(?:\|(\w+:\d+))?\}\}"
r"|\{\{MESSAGES:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}\}"
r")",
replacement_function,
template,
)
@@ -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",
)
+10 -4
View File
@@ -432,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
@@ -703,8 +707,8 @@ def get_functions_from_tool(tool: object) -> list[Callable]:
getattr(tool, func)
) # checks if the attribute is callable (a method or function).
and not func.startswith(
"__"
) # filters out special (dunder) methods like init, str, etc. — these are usually built-in functions of an object that you might not need to use directly.
"_"
) # filters out internal methods (starting with _) and special (dunder) methods.
and not inspect.isclass(
getattr(tool, func)
) # ensures that the callable is not a class itself, just a method or function.
@@ -1186,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"),
@@ -1250,7 +1254,7 @@ async def execute_tool_server(
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))
@@ -1320,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}"
+6 -2
View File
@@ -26,9 +26,13 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b
# Microsoft Teams Webhooks
elif "webhook.office.com" in url:
action = event_data.get("action", "undefined")
user_data = event_data.get("user", "{}")
if isinstance(user_data, dict):
user_dict = user_data
else:
user_dict = json.loads(user_data)
facts = [
{"name": name, "value": value}
for name, value in json.loads(event_data.get("user", {})).items()
{"name": name, "value": value} for name, value in user_dict.items()
]
payload = {
"@type": "MessageCard",
+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
+42 -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,11 @@ psycopg2-binary==2.9.11
pgvector==0.4.2
PyMySQL==1.1.2
boto3==1.42.44
boto3==1.42.62
mariadb==1.1.14
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 +142,18 @@ 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
opentelemetry-instrumentation-system-metrics==0.61b0
+545 -19
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.8.8",
"version": "0.8.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.8.8",
"version": "0.8.10",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@@ -22,6 +22,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",
@@ -66,6 +67,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",
@@ -89,8 +91,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",
@@ -2678,6 +2682,106 @@
"win32"
]
},
"node_modules/@shikijs/core": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.1.tgz",
"integrity": "sha512-vWvqi9JNgz1dRL9Nvog5wtx7RuNkf7MEPl2mU/cyUUxJeH1CAr3t+81h8zO8zs7DK6cKLMoU9TvukWIDjP4Lzg==",
"license": "MIT",
"dependencies": {
"@shikijs/primitive": "4.0.1",
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.1.tgz",
"integrity": "sha512-DJK9NiwtGYqMuKCRO4Ip0FKNDQpmaiS+K5bFjJ7DWFn4zHueDWgaUG8kAofkrnXF6zPPYYQY7J5FYVW9MbZyBg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.1.tgz",
"integrity": "sha512-oCWdCTDch3J8Kc0OZJ98KuUPC02O1VqIE3W/e2uvrHqTxYRR21RGEJMtchrgrxhsoJJCzmIciKsqG+q/yD+Cxg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/langs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.1.tgz",
"integrity": "sha512-v/mluaybWdnGJR4GqAR6zh8qAZohW9k+cGYT28Y7M8+jLbC0l4yG085O1A+WkseHTn+awd+P3UBymb2+MXFc8w==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/primitive": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.1.tgz",
"integrity": "sha512-ns0hHZc5eWZuvuIEJz2pTx3Qecz0aRVYumVQJ8JgWY2tq/dH8WxdcVM49Fc2NsHEILNIT6vfdW9MF26RANWiTA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/themes": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.1.tgz",
"integrity": "sha512-FW41C/D6j/yKQkzVdjrRPiJCtgeDaYRJFEyCKFCINuRJRj9WcmubhP4KQHPZ4+9eT87jruSrYPyoblNRyDFzvA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/types": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.1.tgz",
"integrity": "sha512-EaygPEn57+jJ76mw+nTLvIpJMAcMPokFbrF8lufsZP7Ukk+ToJYEcswN1G0e49nUZAq7aCQtoeW219A8HK1ZOw==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -3266,9 +3370,9 @@
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.20.0.tgz",
"integrity": "sha512-JItmI4U0i4kqorO114u24hM9k945IdaQ6Uc2DEtPBFFuS8cepJf2zw+ulAT1kAx6ZRiNvNpT9M7w+J0mWRn+Sg==",
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.20.1.tgz",
"integrity": "sha512-JnwLvyzrutBffHp6YPnf0XyTnhAgqZ9D8JSUKFp0edvai+dxsb+UMlawesBrgAuoQXw3B8YZUo2VFDVdKas1xQ==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3276,8 +3380,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/y-tiptap": "^3.0.2",
"yjs": "^13"
}
@@ -3525,9 +3629,9 @@
}
},
"node_modules/@tiptap/extension-node-range": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.20.0.tgz",
"integrity": "sha512-XeKKTV88VuJ4Mh0Rxvc/PPzG76cb44sE+rB4u0J/ms63R/WFTm6yJQlCgUVGnGeHleSlrWuZY8gGSuoljmQzqg==",
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.20.1.tgz",
"integrity": "sha512-+W/mQJxlkXMcwldWUqwdoR0eniJ1S9cVJoAy2Lkis0NhILZDWVNGKl9J4WFoCOXn8Myr17IllIxRYvAXJJ4FHQ==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3535,8 +3639,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
"@tiptap/core": "^3.20.1",
"@tiptap/pm": "^3.20.1"
}
},
"node_modules/@tiptap/extension-ordered-list": {
@@ -3606,9 +3710,9 @@
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.0.tgz",
"integrity": "sha512-zyWW1a6W+kaXAn3wv2svJ1XuVMapujftvH7Xn2Q3QmKKiDkO+NiFkrGe8BhMopu8Im51nO3NylIgVA0X1mS1rQ==",
"version": "3.20.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.1.tgz",
"integrity": "sha512-3LQU92zX6tzl47EBskkAKeJXd6EWwYmBDE7jbd7InJqnt9NMAcj4DtXtXpI+e6Un5+8yzNjVA+fI5+5cFS3dSg==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3616,7 +3720,7 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
"@tiptap/core": "^3.20.1"
}
},
"node_modules/@tiptap/extension-typography": {
@@ -4059,6 +4163,15 @@
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
@@ -4122,9 +4235,10 @@
"optional": true
},
"node_modules/@types/unist": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA=="
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
@@ -5191,6 +5305,16 @@
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"dev": true
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
@@ -5239,6 +5363,26 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
@@ -5761,6 +5905,16 @@
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -8068,6 +8222,42 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
@@ -8144,6 +8334,16 @@
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -9589,6 +9789,27 @@
"node": ">= 0.4"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@@ -9661,6 +9882,95 @@
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/micromark-util-character": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-encode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-sanitize-uri": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-encode": "^2.0.0",
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-symbol": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -9976,6 +10286,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
"integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz",
"integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.1",
"regex": "^6.0.1",
"regex-recursion": "^6.0.2"
}
},
"node_modules/onnxruntime-common": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.20.1.tgz",
@@ -10621,6 +10948,16 @@
"node": "10.* || >= 12.*"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
@@ -11073,6 +11410,30 @@
"node": ">=8.10.0"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-recursion": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -11843,6 +12204,25 @@
"node": ">=8"
}
},
"node_modules/shiki": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.1.tgz",
"integrity": "sha512-EkAEhDTN5WhpoQFXFw79OHIrSAfHhlImeCdSyg4u4XvrpxKEmdo/9x/HWSowujAnUrFsGOwWiE58a6GVentMnQ==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "4.0.1",
"@shikijs/engine-javascript": "4.0.1",
"@shikijs/engine-oniguruma": "4.0.1",
"@shikijs/langs": "4.0.1",
"@shikijs/themes": "4.0.1",
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/side-channel": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
@@ -11969,12 +12349,28 @@
"node": ">=0.10.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
@@ -12129,6 +12525,20 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -12662,6 +13072,16 @@
"node": ">= 4.0.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -12818,6 +13238,74 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit-parents": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -13327,6 +13815,34 @@
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-stringify-position": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vinyl": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz",
@@ -14608,6 +15124,16 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
}
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.8.8",
"version": "0.8.10",
"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",
@@ -110,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",
@@ -133,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",
+32 -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",
@@ -137,11 +137,15 @@ postgres = [
"psycopg2-binary==2.9.11",
"pgvector==0.4.2",
]
mariadb = [
"mariadb==1.1.14",
]
all = [
"pymongo",
"psycopg2-binary==2.9.11",
"pgvector==0.4.2",
"mariadb==1.1.14",
"moto[s3]>=5.0.26",
"gcp-storage-emulator>=2024.8.3",
"docker~=7.1.0",
@@ -150,15 +154,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",
]
+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) => {
+101
View File
@@ -5,6 +5,12 @@ export type FileEntry = {
modified?: number;
};
export type ListeningPort = {
port: number;
pid: number | null;
process: string | null;
};
export type TerminalFeatures = {
terminal?: boolean;
};
@@ -235,3 +241,98 @@ export const moveEntry = async (
});
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;
};
+3 -3
View File
@@ -699,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"
@@ -708,9 +708,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
+3 -3
View File
@@ -898,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"
@@ -907,9 +907,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
+3 -3
View File
@@ -104,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"
@@ -113,9 +113,9 @@
{$i18n.t('Import')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -67,7 +67,8 @@
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}</option>
<option value={engine}>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
>
{/each}
</select>
</div>
@@ -193,7 +194,9 @@
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}</option>
<option value={engine}
>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
>
{/each}
</select>
</div>
@@ -221,6 +221,12 @@
const res = await updateRAGConfig(localStorage.token, {
...RAGConfig,
// Convert null (from cleared number inputs) to empty string so the backend
// can distinguish "clear this field" from "don't change this field"
FILE_MAX_SIZE: RAGConfig.FILE_MAX_SIZE ?? '',
FILE_MAX_COUNT: RAGConfig.FILE_MAX_COUNT ?? '',
FILE_IMAGE_COMPRESSION_WIDTH: RAGConfig.FILE_IMAGE_COMPRESSION_WIDTH ?? '',
FILE_IMAGE_COMPRESSION_HEIGHT: RAGConfig.FILE_IMAGE_COMPRESSION_HEIGHT ?? '',
ALLOWED_FILE_EXTENSIONS: RAGConfig.ALLOWED_FILE_EXTENSIONS.split(',')
.map((ext) => ext.trim())
.filter((ext) => ext !== ''),
@@ -1270,7 +1270,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"
@@ -1279,9 +1279,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -64,9 +64,7 @@
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
{/if}
<option value="" disabled hidden class="text-gray-900">{$i18n.t('Type')}</option>
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
@@ -436,7 +436,7 @@
</Tooltip>
</div>
<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"
@@ -445,9 +445,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -219,7 +219,7 @@
{#if ['general', 'permissions'].includes(selectedTab)}
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<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"
@@ -228,9 +228,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -31,7 +31,7 @@
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let orderBy = 'created_at'; // default sort key
let orderBy = groupId ? `group_id:${groupId}` : 'last_active_at'; // default sort key
let direction = 'desc'; // default sort order
let page = 1;
@@ -285,7 +285,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"
@@ -294,9 +294,9 @@
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -160,7 +160,7 @@
<div class="flex justify-end text-sm font-medium gap-1.5">
<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 {isSaving
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 {isSaving
? 'cursor-not-allowed'
: ''}"
type="submit"
@@ -168,9 +168,9 @@
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
+14 -6
View File
@@ -90,24 +90,32 @@
};
onMount(() => {
artifactCode.subscribe((value) => {
const unsubscribeArtifactCode = artifactCode.subscribe((value) => {
if (contents) {
const codeIdx = contents.findIndex((content) => content.content.includes(value));
selectedContentIdx = codeIdx !== -1 ? codeIdx : 0;
}
});
artifactContents.subscribe((value) => {
contents = value;
console.log('Artifact contents updated:', contents);
const unsubscribeArtifactContents = artifactContents.subscribe((value) => {
const newContents = value ?? [];
console.log('Artifact contents updated:', newContents);
if (contents.length === 0) {
if (newContents.length === 0) {
showControls.set(false);
showArtifacts.set(false);
selectedContentIdx = 0;
} else if (newContents.length > contents.length) {
selectedContentIdx = newContents.length - 1;
}
selectedContentIdx = contents ? contents.length - 1 : 0;
contents = newContents;
});
return () => {
unsubscribeArtifactCode();
unsubscribeArtifactContents();
};
});
</script>
+118 -70
View File
@@ -65,6 +65,7 @@
import { AudioQueue } from '$lib/utils/audio';
import {
archiveChatById,
createNewChat,
getAllTags,
getChatById,
@@ -104,6 +105,7 @@
import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import Image from '../common/Image.svelte';
import { getBanners } from '$lib/apis/configs';
export let chatIdProp = '';
@@ -405,11 +407,14 @@
};
const terminalEventHandler = (type: string, data: any) => {
if (!data?.path) return;
if (type === 'terminal:display_file') {
if (!data?.path) return;
displayFileHandler(data.path, { showControls, showFileNavPath });
} else if (type === 'terminal:write_file') {
} else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') {
if (!data?.path) return;
showFileNavDir.set(data.path);
} else if (type === 'terminal:run_command') {
showFileNavDir.set('/');
}
};
@@ -644,6 +649,13 @@
if (p.url.pathname === '/') {
await tick();
initNewChat();
// Re-fetch banners on navigation to homepage so newly configured banners appear
try {
banners.set(await getBanners(localStorage.token).catch(() => []));
} catch (e) {
console.error('Failed to refresh banners:', e);
}
}
stopAudio();
@@ -922,15 +934,19 @@
}
};
$: if (history) {
cancelAnimationFrame(contentsRAF);
contentsRAF = requestAnimationFrame(() => {
getContents();
contentsRAF = null;
});
} else {
artifactContents.set([]);
}
const onHistoryChange = (history) => {
if (history) {
cancelAnimationFrame(contentsRAF);
contentsRAF = requestAnimationFrame(() => {
getContents();
contentsRAF = null;
});
} else {
artifactContents.set([]);
}
};
$: onHistoryChange(history);
const getContents = () => {
const messages = history ? createMessagesList(history, history.currentId) : [];
@@ -1561,27 +1577,29 @@
navigator.vibrate(5);
}
// Emit chat event for TTS
const messageContentParts = getMessageContentParts(
removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation'
);
messageContentParts.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
messageContentParts.length > 0 &&
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
) {
message.lastSentence = messageContentParts[messageContentParts.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: messageContentParts[messageContentParts.length - 1]
}
})
// Emit chat event for TTS (only when call overlay is active)
if ($showCallOverlay) {
const messageContentParts = getMessageContentParts(
removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation'
);
messageContentParts.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
messageContentParts.length > 0 &&
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
) {
message.lastSentence = messageContentParts[messageContentParts.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: messageContentParts[messageContentParts.length - 1]
}
})
);
}
}
}
}
@@ -1595,27 +1613,29 @@
navigator.vibrate(5);
}
// Emit chat event for TTS
const messageContentParts = getMessageContentParts(
removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation'
);
messageContentParts.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
messageContentParts.length > 0 &&
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
) {
message.lastSentence = messageContentParts[messageContentParts.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: messageContentParts[messageContentParts.length - 1]
}
})
// Emit chat event for TTS (only when call overlay is active)
if ($showCallOverlay) {
const messageContentParts = getMessageContentParts(
removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation'
);
messageContentParts.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
messageContentParts.length > 0 &&
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
) {
message.lastSentence = messageContentParts[messageContentParts.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: messageContentParts[messageContentParts.length - 1]
}
})
);
}
}
}
@@ -1642,18 +1662,20 @@
document.getElementById(`speak-button-${message.id}`)?.click();
}
// Emit chat event for TTS
let lastMessageContentPart =
getMessageContentParts(
removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation'
)?.at(-1) ?? '';
if (lastMessageContentPart) {
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: { id: message.id, content: lastMessageContentPart }
})
);
// Emit chat event for TTS (only when call overlay is active)
if ($showCallOverlay) {
let lastMessageContentPart =
getMessageContentParts(
removeAllDetails(message.content),
$config?.audio?.tts?.split_on ?? 'punctuation'
)?.at(-1) ?? '';
if (lastMessageContentPart) {
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: { id: message.id, content: lastMessageContentPart }
})
);
}
}
eventTarget.dispatchEvent(
new CustomEvent('chat:finish', {
@@ -1991,6 +2013,17 @@
return features;
};
const getStopTokens = () => {
const stop = params?.stop ?? $settings?.params?.stop;
if (!stop) return undefined;
const tokens = Array.isArray(stop) ? stop : stop.split(',').map((s) => s.trim());
return tokens
.filter(Boolean)
.map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`)));
};
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
@@ -2152,12 +2185,7 @@
params: {
...$settings?.params,
...params,
stop:
(params?.stop ?? $settings?.params?.stop ?? undefined)
? (params?.stop.split(',').map((token) => token.trim()) ?? $settings.params.stop).map(
(str) => decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
)
: undefined
stop: getStopTokens()
},
files: (files?.length ?? 0) > 0 ? files : undefined,
@@ -2585,6 +2613,25 @@
toast.error($i18n.t('Failed to move chat'));
}
};
const archiveChatHandler = async (id: string) => {
try {
await archiveChatById(localStorage.token, id);
currentChatPage.set(1);
initNewChat();
await goto('/');
getChatList(localStorage.token, $currentChatPage).then((chats) => {
chats.set(chats);
});
getPinnedChatList(localStorage.token).then((pinnedChats) => {
pinnedChats.set(pinnedChats);
});
toast.success($i18n.t('Chat archived.'));
} catch (error) {
console.error('Error archiving chat:', error);
toast.error($i18n.t('Failed to archive chat.'));
}
};
</script>
<svelte:head>
@@ -2667,7 +2714,7 @@
bind:selectedModels
shareEnabled={!!history.currentId}
{initNewChat}
archiveChatHandler={() => {}}
{archiveChatHandler}
{moveChatHandler}
onSaveTempChat={async () => {
try {
@@ -2877,6 +2924,7 @@
{stopResponse}
{showMessage}
{eventTarget}
{codeInterpreterEnabled}
/>
</PaneGroup>
</div>
+17 -4
View File
@@ -10,6 +10,7 @@
import { onDestroy, onMount, tick, getContext } from 'svelte';
import {
config,
terminalServers,
mobile,
showControls,
@@ -31,6 +32,7 @@
import Artifacts from './Artifacts.svelte';
import Embeds from './ChatControls/Embeds.svelte';
import FileNav from './FileNav.svelte';
import PyodideFileNav from './PyodideFileNav.svelte';
import Overview from './Overview.svelte';
const i18n = getContext('i18n');
@@ -50,6 +52,8 @@
export let files;
export let modelId;
export let codeInterpreterEnabled = false;
export let pane: Pane | null = null;
let largeScreen = false;
@@ -67,7 +71,9 @@
$: hasMessages = history?.messages && Object.keys(history.messages).length > 0;
$: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true);
$: showFilesTab = !!$selectedTerminalId;
$: showFilesTab =
!!$selectedTerminalId ||
(codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter');
$: showOverviewTab = hasMessages;
// Tab fallback: if active tab becomes hidden, switch to next available
@@ -342,7 +348,7 @@
? 'h-full'
: activeTab === 'controls'
? 'overflow-y-auto px-3 pt-1'
: 'overflow-y-auto'}"
: ''}"
>
{#if activeTab === 'overview'}
<Overview
@@ -355,6 +361,8 @@
/>
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
{/if}
@@ -401,7 +409,10 @@
<div
class="w-full {specialPanel && !$showCallOverlay
? ' '
: 'bg-white dark:shadow-lg dark:bg-gray-850'} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
: 'bg-white dark:shadow-lg dark:bg-gray-850'} z-40 pointer-events-auto {activeTab ===
'files'
? ''
: 'overflow-y-auto'} scrollbar-hidden"
id="controls-container"
>
{#if $showCallOverlay}
@@ -483,7 +494,7 @@
? 'h-full'
: activeTab === 'controls'
? 'overflow-y-auto px-3 pt-1'
: 'overflow-y-auto'}"
: ''}"
>
{#if activeTab === 'overview'}
<Overview
@@ -501,6 +512,8 @@
/>
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} overlay={dragged} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
{/if}
+249 -34
View File
@@ -26,6 +26,7 @@
setCwd,
type FileEntry
} from '$lib/apis/terminal';
import { isCodeFile } from '$lib/utils/codeHighlight';
import Folder from '../icons/Folder.svelte';
import Document from '../icons/Document.svelte';
import PenAlt from '../icons/PenAlt.svelte';
@@ -37,6 +38,7 @@
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FilePreview from './FileNav/FilePreview.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte';
import PortList from './FileNav/PortList.svelte';
import XTerminal from './XTerminal.svelte';
const i18n = getContext('i18n');
@@ -89,10 +91,21 @@
let selectedFile: string | null = null;
let fileContent: string | null = null;
let fileImageUrl: string | null = null;
let fileVideoUrl: string | null = null;
let fileAudioUrl: string | null = null;
let filePdfData: ArrayBuffer | null = null;
let fileSqliteData: ArrayBuffer | null = null;
let fileLoading = false;
let filePreviewRef: FilePreview;
// ── Office preview state ────────────────────────────────────────────
let fileOfficeHtml: string | null = null;
let fileOfficeSlides: string[] | null = null;
let currentSlide = 0;
let excelSheetNames: string[] = [];
let selectedExcelSheet = '';
let excelWorkbook: import('xlsx').WorkBook | null = null;
// ── File preview toolbar state (bound from FilePreview) ─────────────
let editing = false;
let showRaw = false;
@@ -101,12 +114,19 @@
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
const CSV_EXTS = new Set(['csv', 'tsv']);
const HTML_EXTS = new Set(['html', 'htm']);
const OFFICE_EXTS = new Set(['docx', 'xlsx', 'pptx']);
const getFileExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
$: isMarkdown = MD_EXTS.has(getFileExt(selectedFile));
$: isCsv = CSV_EXTS.has(getFileExt(selectedFile));
$: isHtml = HTML_EXTS.has(getFileExt(selectedFile));
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
$: isJson = ['json', 'jsonc', 'jsonl', 'json5'].includes(getFileExt(selectedFile));
$: isSvg = getFileExt(selectedFile) === 'svg';
$: isNotebook = getFileExt(selectedFile) === 'ipynb';
$: isCode = isCodeFile(selectedFile);
$: isOfficeFile = OFFICE_EXTS.has(getFileExt(selectedFile));
$: isTextFile =
fileContent !== null && fileImageUrl === null && filePdfData === null && !isOfficeFile;
// ── Upload / folder creation ─────────────────────────────────────────
let isDragOver = false;
@@ -157,7 +177,8 @@
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
const cwd = await getCwd(terminal.url, terminal.key);
const rawCwd = await getCwd(terminal.url, terminal.key);
const cwd = rawCwd ? normalizePath(rawCwd) : null;
const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/';
savedPath = dir;
loadDir(dir);
@@ -166,22 +187,33 @@
}
// ── Helpers ──────────────────────────────────────────────────────────
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']);
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus']);
const SQLITE_EXTS = new Set(['db', 'sqlite', 'sqlite3', 'db3']);
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isVideo = (path: string) => VIDEO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isAudio = (path: string) => AUDIO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isSqlite = (path: string) => SQLITE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const buildBreadcrumbs = (path: string) =>
path
.split('/')
.filter(Boolean)
.reduce(
(acc, part) => {
const prev = acc[acc.length - 1];
acc.push({ label: part, path: `${prev.path}${part}/` });
return acc;
},
[{ label: '/', path: '/' }]
);
/** Normalize Windows backslashes to forward slashes. */
const normalizePath = (p: string) => p.replace(/\\/g, '/');
const buildBreadcrumbs = (path: string) => {
const parts = path.split('/').filter(Boolean);
const isDrive = /^[A-Za-z]:$/.test(parts[0] ?? '');
const root = isDrive ? { label: parts[0], path: `${parts[0]}/` } : { label: '/', path: '/' };
return (isDrive ? parts.slice(1) : parts).reduce(
(acc, part) => {
const prev = acc[acc.length - 1];
acc.push({ label: part, path: `${prev.path}${part}/` });
return acc;
},
[root]
);
};
// ── File preview management ──────────────────────────────────────────
const clearFilePreview = () => {
@@ -191,7 +223,22 @@
URL.revokeObjectURL(fileImageUrl);
fileImageUrl = null;
}
if (fileVideoUrl) {
URL.revokeObjectURL(fileVideoUrl);
fileVideoUrl = null;
}
if (fileAudioUrl) {
URL.revokeObjectURL(fileAudioUrl);
fileAudioUrl = null;
}
filePdfData = null;
fileSqliteData = null;
fileOfficeHtml = null;
fileOfficeSlides = null;
currentSlide = 0;
excelSheetNames = [];
selectedExcelSheet = '';
excelWorkbook = null;
};
// ── Directory operations ─────────────────────────────────────────────
@@ -241,9 +288,51 @@
if (isImage(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) fileImageUrl = URL.createObjectURL(result.blob);
} else if (isVideo(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) fileVideoUrl = URL.createObjectURL(result.blob);
} else if (isAudio(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) fileAudioUrl = URL.createObjectURL(result.blob);
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) filePdfData = await result.blob.arrayBuffer();
} else if (isSqlite(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) {
const ext = getFileExt(filePath);
const arrayBuffer = await result.blob.arrayBuffer();
try {
if (ext === 'docx') {
const mammoth = await import('mammoth');
const res = await mammoth.convertToHtml({ arrayBuffer });
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(res.value);
} else if (ext === 'xlsx') {
const XLSX = await import('xlsx');
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelWorkbook = wb;
excelSheetNames = wb.SheetNames;
if (excelSheetNames.length > 0) {
selectedExcelSheet = excelSheetNames[0];
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(wb.Sheets[selectedExcelSheet]);
fileOfficeHtml = result.html;
}
} else if (ext === 'pptx') {
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
currentSlide = 0;
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
}
}
} else {
fileContent = await readFile(terminal.url, terminal.key, filePath);
}
@@ -402,40 +491,49 @@
if (!filePath || !selectedTerminal) return;
handledDisplayFile = true;
showFileNavPath.set(null);
filePath = normalizePath(filePath);
const lastSlash = filePath.lastIndexOf('/');
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash + 1) : '/';
const fileName = filePath.substring(lastSlash + 1);
if (dir !== currentPath) {
await loadDir(dir);
}
// Always reload directory to ensure entries are fresh
await loadDir(dir);
await tick();
const entry = entries.find((e) => e.name === fileName);
if (entry) await openEntry(entry);
if (entry) {
await openEntry(entry);
} else {
// File may not be in listing; open it directly
await openEntry({ name: fileName, type: 'file', size: 0 });
}
});
const unsubFileNavDir = showFileNavDir.subscribe(async (filePath) => {
if (!filePath || !selectedTerminal) return;
showFileNavDir.set(null);
filePath = normalizePath(filePath);
const lastSlash = filePath.lastIndexOf('/');
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash + 1) : '/';
if (dir === currentPath) {
await loadDir(currentPath);
}
if (filePath === selectedFile) {
const fileName = filePath.substring(lastSlash + 1);
const entry = entries.find((e) => e.name === fileName);
if (entry) await openEntry(entry);
if (selectedFile) {
if (selectedFile === filePath || currentPath.startsWith(dir)) {
const fileName = selectedFile.split('/').pop() ?? '';
await openEntry({ name: fileName, type: 'file', size: 0 });
}
} else {
if (currentPath.startsWith(dir) || dir.startsWith(currentPath)) {
await loadDir(currentPath);
}
}
});
if (!handledDisplayFile) {
if (savedPath === '/') {
const cwd = await getCwd(terminal.url, terminal.key);
const rawCwd = await getCwd(terminal.url, terminal.key);
const cwd = rawCwd ? normalizePath(rawCwd) : null;
if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/';
}
loadDir(savedPath);
@@ -461,6 +559,8 @@
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
unsubFileNav();
unsubFileNavDir();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
@@ -470,6 +570,8 @@
onDestroy(() => {
if (fileImageUrl) URL.revokeObjectURL(fileImageUrl);
if (fileVideoUrl) URL.revokeObjectURL(fileVideoUrl);
if (fileAudioUrl) URL.revokeObjectURL(fileAudioUrl);
});
</script>
@@ -496,7 +598,7 @@
{:else}
<div
bind:this={containerEl}
class="flex flex-col h-full min-h-0 relative"
class="flex flex-col h-full min-h-0 min-w-0 relative"
on:dragover={handleDragOver}
on:dragleave={() => (isDragOver = false)}
on:drop={handleDrop}
@@ -530,13 +632,20 @@
{selectedFile}
{loading}
onNavigate={loadDir}
onRefresh={() => loadDir(currentPath)}
onRefresh={() => {
if (selectedFile) {
const fileName = selectedFile.split('/').pop() ?? '';
openEntry({ name: fileName, type: 'file', size: 0 });
} else {
loadDir(currentPath);
}
}}
onNewFolder={startNewFolder}
onNewFile={startNewFile}
onUploadFiles={handleUploadFiles}
onMove={handleMove}
>
{#if fileImageUrl !== null}
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
<Tooltip content={$i18n.t('Reset view')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -558,7 +667,7 @@
</button>
</Tooltip>
{/if}
{#if (isMarkdown || isCsv || isHtml) && fileContent !== null && !editing}
{#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -608,7 +717,61 @@
</Tooltip>
{/if}
{#if isTextFile}
{#if editing}
{#if isHtml && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if isHtml}
<!-- HTML preview mode: no edit/save buttons -->
{:else if isCode}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if editing}
<Tooltip content={$i18n.t('Cancel')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -664,6 +827,34 @@
</Tooltip>
{/if}
{/if}
{#if fileContent !== null}
<Tooltip content={$i18n.t('Copy')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={async () => {
await navigator.clipboard.writeText(fileContent ?? '');
toast.success($i18n.t('Copied to clipboard'));
}}
aria-label={$i18n.t('Copy')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -689,18 +880,35 @@
</FileNavToolbar>
<!-- Content -->
<div class="flex-1 overflow-y-auto min-h-0">
<div class="flex-1 overflow-y-auto min-h-0 min-w-0">
{#if selectedFile !== null}
<FilePreview
bind:this={filePreviewRef}
bind:editing
bind:showRaw
bind:saving
bind:currentSlide
{selectedFile}
{fileLoading}
{fileImageUrl}
{fileVideoUrl}
{fileAudioUrl}
{filePdfData}
{fileSqliteData}
{fileContent}
{fileOfficeHtml}
{fileOfficeSlides}
{excelSheetNames}
{selectedExcelSheet}
onSheetChange={async (sheet) => {
if (!excelWorkbook) return;
selectedExcelSheet = sheet;
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(excelWorkbook.Sheets[sheet]);
fileOfficeHtml = result.html;
}}
baseUrl={selectedTerminal?.url ?? ''}
apiKey={selectedTerminal?.key ?? ''}
{overlay}
onSave={async (content) => {
const terminal = selectedTerminal;
@@ -797,6 +1005,13 @@
{/if}
</div>
<!-- Port detection -->
{#if selectedTerminal && !selectedFile}
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800">
<PortList baseUrl={selectedTerminal.url} apiKey={selectedTerminal.key} />
</div>
{/if}
<!-- Terminal bottom panel -->
{#if terminalEnabled}
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850">
@@ -0,0 +1,114 @@
<script lang="ts">
import '$lib/utils/codemirror';
import { basicSetup, EditorView } from 'codemirror';
import { keymap } from '@codemirror/view';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import { indentWithTab } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { oneDark } from '@codemirror/theme-one-dark';
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let value = '';
export let lang = 'python';
let container: HTMLDivElement;
let editor: EditorView | null = null;
let editorTheme = new Compartment();
let editorLanguage = new Compartment();
const getLang = async () => {
const language = languages.find((l) => l.alias.includes(lang));
return await language?.load();
};
onMount(async () => {
const isDark = document.documentElement.classList.contains('dark');
const extensions = [
Prec.highest(
keymap.of([
{
key: 'Mod-Enter',
run: () => {
dispatch('run');
return true;
}
},
{
key: 'Escape',
run: () => {
dispatch('cancel');
return true;
}
}
])
),
basicSetup,
keymap.of([indentWithTab]),
indentUnit.of(' '),
EditorView.updateListener.of((e) => {
if (e.docChanged) {
value = e.state.doc.toString();
dispatch('change', value);
}
}),
editorTheme.of(isDark ? oneDark : []),
editorLanguage.of([]),
EditorView.theme({
'&': { fontSize: '0.75rem' },
'.cm-content': {
padding: '0.35rem 0',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
},
'.cm-gutters': { display: 'none' },
'.cm-focused': { outline: 'none' },
'.cm-scroller': { overflow: 'auto' }
})
];
editor = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: container
});
const language = await getLang();
if (language && editor) {
editor.dispatch({ effects: editorLanguage.reconfigure(language) });
}
// Watch dark mode
const observer = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark');
editor?.dispatch({ effects: editorTheme.reconfigure(dark ? oneDark : []) });
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
editor.focus();
return () => {
observer.disconnect();
editor?.destroy();
editor = null;
};
});
onDestroy(() => {
editor?.destroy();
editor = null;
});
</script>
<div bind:this={container} class="nb-cm-editor" />
<style>
.nb-cm-editor {
width: 100%;
background: #fffef5;
}
:global(.dark) .nb-cm-editor {
background: transparent;
}
</style>
@@ -0,0 +1,139 @@
<script lang="ts">
import '$lib/utils/codemirror';
import { basicSetup, EditorView } from 'codemirror';
import { keymap } from '@codemirror/view';
import { Compartment, EditorState } from '@codemirror/state';
import { indentWithTab } from '@codemirror/commands';
import { indentUnit, LanguageDescription } from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { oneDark } from '@codemirror/theme-one-dark';
import { onMount, onDestroy } from 'svelte';
export let value = '';
export let filePath: string | null = null;
export let onSave: ((content: string) => Promise<void>) | null = null;
let container: HTMLDivElement;
let editor: EditorView | null = null;
let editorTheme = new Compartment();
let editorLanguage = new Compartment();
let internalValue = '';
/** Return the current editor content */
export const getValue = (): string => {
return editor?.state.doc.toString() ?? value;
};
/** Replace editor content */
export const setValue = (newValue: string) => {
if (!editor) return;
internalValue = newValue;
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: newValue }
});
};
export const focus = () => {
editor?.focus();
};
const detectLanguage = async (path: string | null) => {
if (!path) return;
const match = LanguageDescription.matchFilename(languages, path);
if (match) {
const lang = await match.load();
if (lang && editor) {
editor.dispatch({ effects: editorLanguage.reconfigure(lang) });
}
}
};
// React to external value changes (e.g. switching files)
$: if (editor && value !== internalValue) {
internalValue = value;
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value }
});
}
// React to filePath changes for language detection
$: if (editor && filePath) {
detectLanguage(filePath);
}
onMount(() => {
const isDark = document.documentElement.classList.contains('dark');
internalValue = value;
const extensions = [
basicSetup,
keymap.of([
indentWithTab,
{
key: 'Mod-s',
run: () => {
if (onSave) {
onSave(editor?.state.doc.toString() ?? '');
}
return true;
}
}
]),
indentUnit.of(' '),
EditorView.updateListener.of((e) => {
if (e.docChanged) {
internalValue = e.state.doc.toString();
value = internalValue;
}
}),
editorTheme.of(isDark ? oneDark : []),
editorLanguage.of([]),
EditorView.theme({
'&': { fontSize: '0.75rem', height: '100%' },
'.cm-content': {
padding: '0.5rem 0',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
},
'.cm-scroller': { overflow: 'auto' },
'.cm-focused': { outline: 'none' }
})
];
editor = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: container
});
detectLanguage(filePath);
// Watch dark mode
const observer = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark');
editor?.dispatch({ effects: editorTheme.reconfigure(dark ? oneDark : []) });
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
editor?.destroy();
editor = null;
};
});
onDestroy(() => {
editor?.destroy();
editor = null;
});
</script>
<div bind:this={container} class="file-code-editor" />
<style>
.file-code-editor {
width: 100%;
height: 100%;
}
.file-code-editor :global(.cm-editor) {
height: 100%;
}
</style>
@@ -12,8 +12,8 @@
export let entry: FileEntry;
export let currentPath: string;
export let terminalUrl: string;
export let terminalKey: string;
export let terminalUrl: string = '';
export let terminalKey: string = '';
export let onOpen: (entry: FileEntry) => void = () => {};
export let onDownload: (path: string) => void = () => {};
@@ -81,27 +81,28 @@
{/if}
</div>
{#if !selectedFile}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onRefresh}
aria-label={$i18n.t('Refresh')}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onRefresh}
aria-label={$i18n.t('Refresh')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5 {loading ? 'animate-spin' : ''}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5 {loading ? 'animate-spin' : ''}"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
{#if !selectedFile}
<Tooltip content={$i18n.t('New Folder')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
@@ -4,19 +4,41 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { settings } from '$lib/stores';
import { isCodeFile } from '$lib/utils/codeHighlight';
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
import Spinner from '../../common/Spinner.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import JsonTreeView from './JsonTreeView.svelte';
import NotebookView from './NotebookView.svelte';
import SqliteView from './SqliteView.svelte';
import FileCodeEditor from './FileCodeEditor.svelte';
let pdfViewerRef: PDFViewer;
let fileCodeEditorRef: FileCodeEditor;
const i18n = getContext('i18n');
export let selectedFile: string | null = null;
export let fileLoading = false;
export let fileImageUrl: string | null = null;
export let fileVideoUrl: string | null = null;
export let fileAudioUrl: string | null = null;
export let filePdfData: ArrayBuffer | null = null;
export let fileSqliteData: ArrayBuffer | null = null;
export let fileContent: string | null = null;
// Terminal connection for notebook execution
export let baseUrl: string = '';
export let apiKey: string = '';
// Office preview props
export let fileOfficeHtml: string | null = null;
export let fileOfficeSlides: string[] | null = null;
export let currentSlide = 0;
export let excelSheetNames: string[] = [];
export let selectedExcelSheet = '';
export let onSheetChange: ((sheet: string) => void) | null = null;
export let overlay = false;
export let onSave: ((content: string) => Promise<void>) | null = null;
@@ -56,22 +78,71 @@
editContent = '';
};
/** Save code file directly from CodeMirror */
export const saveCodeFile = async () => {
if (!onSave) return;
saving = true;
const content = fileCodeEditorRef?.getValue() ?? '';
await onSave(content);
saving = false;
};
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
const CSV_EXTS = new Set(['csv', 'tsv']);
const HTML_EXTS = new Set(['html', 'htm']);
const JSON_EXTS = new Set(['json', 'jsonc', 'jsonl', 'json5']);
const getExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
$: isMarkdown = MD_EXTS.has(getExt(selectedFile));
$: isCsv = CSV_EXTS.has(getExt(selectedFile));
$: isHtml = HTML_EXTS.has(getExt(selectedFile));
$: isJson = JSON_EXTS.has(getExt(selectedFile));
$: isSvg = getExt(selectedFile) === 'svg';
$: isNotebook = getExt(selectedFile) === 'ipynb';
$: isCode = isCodeFile(selectedFile);
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
$: renderedHtml =
isMarkdown && fileContent
? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string)
: '';
let markdownEl: HTMLDivElement;
let mermaidInstance: any = null;
const renderMermaidBlocks = async (el: HTMLDivElement) => {
if (!el) return;
const codeEls = el.querySelectorAll('code.language-mermaid');
if (codeEls.length === 0) return;
if (!mermaidInstance) {
mermaidInstance = await initMermaid();
}
for (const codeEl of codeEls) {
const pre = codeEl.parentElement;
if (!pre || pre.tagName !== 'PRE' || pre.dataset.mermaidRendered) continue;
pre.dataset.mermaidRendered = 'true';
try {
const svg = await renderMermaidDiagram(mermaidInstance, codeEl.textContent ?? '');
if (svg) {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram flex justify-center py-2';
wrapper.innerHTML = svg;
pre.replaceWith(wrapper);
}
} catch (e) {
console.error('Mermaid render error:', e);
}
}
};
$: if (renderedHtml && markdownEl) {
tick().then(() => renderMermaidBlocks(markdownEl));
}
// Simple CSV parser that handles quoted fields
const parseCsv = (text: string, delimiter: string): string[][] => {
const rows: string[][] = [];
@@ -115,6 +186,61 @@
$: csvHeader = csvRows.length > 0 ? csvRows[0] : [];
$: csvBody = csvRows.length > 1 ? csvRows.slice(1) : [];
// ── Shiki code highlighting (SVG only) ──────────────────────────────
let highlightedHtml: string | null = null;
let highlightingFile: string | null = null;
$: if (isSvg && fileContent !== null && selectedFile) {
const currentFile = selectedFile;
highlightingFile = currentFile;
import('shiki')
.then(({ codeToHtml }) =>
codeToHtml(fileContent!, {
lang: 'xml',
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: 'light'
})
)
.then((html) => {
if (highlightingFile === currentFile) highlightedHtml = html;
})
.catch(() => {
if (highlightingFile === currentFile) highlightedHtml = null;
});
} else {
highlightedHtml = null;
}
// ── JSON parsing ────────────────────────────────────────────────────
let parsedJson: unknown = undefined;
let jsonError: string | null = null;
$: if (isJson && fileContent !== null) {
try {
parsedJson = JSON.parse(fileContent);
jsonError = null;
} catch (e) {
parsedJson = undefined;
jsonError = e instanceof Error ? e.message : 'Invalid JSON';
}
} else {
parsedJson = undefined;
jsonError = null;
}
// ── Notebook parsing ─────────────────────────────────────────────────
let parsedNotebook: Record<string, unknown> | null = null;
$: if (isNotebook && fileContent !== null) {
try {
parsedNotebook = JSON.parse(fileContent);
} catch {
parsedNotebook = null;
}
} else {
parsedNotebook = null;
}
export let showRaw = false;
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
@@ -124,7 +250,8 @@
pzInstance = panzoom(node, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
zoomSpeed: 0.065,
zoomDoubleClickSpeed: 1
});
};
@@ -152,9 +279,9 @@
</script>
<div
class="flex-1 {fileImageUrl !== null
class="flex-1 {fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)
? 'overflow-hidden'
: 'overflow-y-auto'} min-h-0 relative h-full"
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
>
<!-- File preview -->
{#if fileLoading}
@@ -168,8 +295,109 @@
draggable="false"
/>
</div>
{:else if fileVideoUrl !== null}
<div class="w-full h-full flex items-center justify-center bg-black">
<!-- svelte-ignore a11y-media-has-caption -->
<video src={fileVideoUrl} controls class="max-w-full max-h-full">
{$i18n.t('Your browser does not support the video tag.')}
</video>
</div>
{:else if fileAudioUrl !== null}
<div class="w-full h-full flex items-center justify-center p-6">
<audio src={fileAudioUrl} controls class="w-full max-w-md">
{$i18n.t('Your browser does not support the audio tag.')}
</audio>
</div>
{:else if filePdfData !== null}
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
{:else if fileSqliteData !== null}
<SqliteView data={fileSqliteData} />
{:else if fileOfficeHtml !== null}
<div class="flex flex-col h-full">
<div class="office-preview overflow-auto flex-1 min-h-0">
{@html fileOfficeHtml}
</div>
{#if excelSheetNames.length > 1}
<div
class="flex items-center gap-1 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 overflow-x-auto"
>
{#each excelSheetNames as sheet}
<button
class="shrink-0 px-3 py-1 text-xs rounded-md transition-colors
{selectedExcelSheet === sheet
? 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => onSheetChange?.(sheet)}
>
{sheet}
</button>
{/each}
</div>
{/if}
</div>
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
<div class="flex flex-col h-full">
<div
class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
use:initImagePanzoom
>
<img
src={fileOfficeSlides[currentSlide]}
alt="Slide {currentSlide + 1}"
class="max-w-full max-h-full object-contain p-3"
draggable="false"
/>
</div>
{#if fileOfficeSlides.length > 1}
<div
class="flex items-center justify-center gap-3 py-2 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500"
>
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={currentSlide === 0}
on:click={() => {
resetImageView();
currentSlide = Math.max(0, currentSlide - 1);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<span>{currentSlide + 1} / {fileOfficeSlides.length}</span>
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={currentSlide === fileOfficeSlides.length - 1}
on:click={() => {
resetImageView();
currentSlide = Math.min(fileOfficeSlides.length - 1, currentSlide + 1);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
{:else if fileContent !== null}
{#if isHtml && !showRaw}
{#if overlay}
@@ -183,13 +411,22 @@
class="w-full h-full border-none bg-white"
title="HTML Preview"
/>
{:else if isHtml && showRaw}
<div class="h-full">
<FileCodeEditor
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
/>
</div>
{:else if isMarkdown && !showRaw}
<div class="prose dark:prose-invert max-w-full text-sm p-3">
<div bind:this={markdownEl} class="prose dark:prose-invert max-w-full text-sm p-3">
{@html renderedHtml}
</div>
{:else if isCsv && !showRaw && csvRows.length > 0}
<div class="overflow-auto h-full px-3 pb-3">
<table class="csv-table w-full text-xs font-mono border-collapse">
<div class="absolute inset-0 overflow-auto px-3 pb-3">
<table class="csv-table text-xs font-mono border-collapse">
<thead>
<tr>
<th class="csv-row-num">#</th>
@@ -214,6 +451,40 @@
</tbody>
</table>
</div>
{:else if isNotebook && !showRaw && parsedNotebook}
<div class="overflow-auto h-full">
<NotebookView notebook={parsedNotebook} filePath={selectedFile ?? ''} {baseUrl} {apiKey} />
</div>
{:else if isJson && !showRaw && parsedJson !== undefined}
<div class="overflow-auto h-full">
<JsonTreeView data={parsedJson} />
</div>
{:else if isJson && !showRaw && jsonError}
<div class="p-3 text-xs">
<div class="text-red-500 mb-2">JSON parse error: {jsonError}</div>
<pre
class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed">{fileContent}</pre>
</div>
{:else if isSvg && !showRaw && fileContent}
<div class="svg-preview w-full h-full flex items-center justify-center overflow-auto p-3">
{@html DOMPurify.sanitize(fileContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use']
})}
</div>
{:else if isCode && !showRaw}
<div class="h-full">
<FileCodeEditor
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
/>
</div>
{:else if isSvg && highlightedHtml && !showRaw}
<div class="shiki-preview overflow-auto h-full text-xs">
{@html highlightedHtml}
</div>
{:else if editing}
<textarea
bind:this={editTextarea}
@@ -226,7 +497,7 @@
class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed p-3">{fileContent}</pre>
{/if}
{:else}
<div class="text-sm text-gray-400 text-center pt-8">
<div class="text-xs text-gray-400 text-center pt-8">
{$i18n.t('Could not read file.')}
</div>
{/if}
@@ -284,4 +555,171 @@
:global(.dark) .csv-row-num {
color: #6b7280;
}
/* ── Office preview styles ──────────────────────────────────────── */
:global(.office-preview) {
font-size: 0.875rem;
line-height: 1.6;
color: #1f2937;
background: #fff;
border-radius: 4px;
}
:global(.dark .office-preview) {
color: #e5e7eb;
background: #1a1a2e;
}
:global(.office-preview table) {
border-collapse: collapse;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
line-height: 1.3;
}
:global(.office-preview table td),
:global(.office-preview table th) {
border: 1px solid rgba(200, 200, 200, 0.5);
padding: 4px 10px;
text-align: left;
white-space: nowrap;
user-select: text;
cursor: cell;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark .office-preview table td),
:global(.dark .office-preview table th) {
border-color: rgba(80, 80, 80, 0.5);
}
/* Column letter headers */
:global(.office-preview table th.excel-col-hdr) {
position: sticky;
top: 0;
z-index: 2;
background: #f0f0f0;
color: #666;
font-weight: 500;
font-size: 0.65rem;
text-align: center;
padding: 3px 10px;
border-bottom: 2px solid rgba(180, 180, 180, 0.6);
}
:global(.dark .office-preview table th.excel-col-hdr) {
background: #2a2a3e;
color: #888;
border-bottom-color: rgba(100, 100, 100, 0.6);
}
/* Row number cells */
:global(.office-preview .excel-row-num) {
position: sticky;
left: 0;
z-index: 1;
background: #f0f0f0;
color: #999;
font-size: 0.6rem;
text-align: right !important;
padding: 4px 8px 4px 4px !important;
user-select: none;
width: 1px;
white-space: nowrap;
border-right: 2px solid rgba(180, 180, 180, 0.6) !important;
}
:global(.dark .office-preview .excel-row-num) {
background: #2a2a3e;
color: #666;
border-right-color: rgba(100, 100, 100, 0.6) !important;
}
/* Corner cell (intersection of row nums and col headers) */
:global(.office-preview thead .excel-row-num) {
z-index: 3;
}
/* Number cells right-aligned */
:global(.office-preview .excel-num) {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Row hover and selection */
:global(.office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) {
background: rgba(0, 0, 0, 0.015);
}
:global(.dark .office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) {
background: rgba(255, 255, 255, 0.02);
}
:global(.office-preview table tbody tr:hover td:not(.excel-row-num)) {
background: rgba(59, 130, 246, 0.06);
}
:global(.dark .office-preview table tbody tr:hover td:not(.excel-row-num)) {
background: rgba(59, 130, 246, 0.1);
}
:global(.office-preview table td:focus) {
outline: 2px solid rgba(59, 130, 246, 0.5);
outline-offset: -2px;
}
/* DOCX / generic office styles */
:global(.office-preview img) {
max-width: 100%;
height: auto;
}
:global(.office-preview h1) {
font-size: 1.5rem;
font-weight: 700;
margin: 0.75em 0 0.5em;
}
:global(.office-preview h2) {
font-size: 1.25rem;
font-weight: 600;
margin: 0.75em 0 0.5em;
}
:global(.office-preview h3) {
font-size: 1.1rem;
font-weight: 600;
margin: 0.5em 0 0.25em;
}
:global(.office-preview p) {
margin: 0.25em 0;
}
:global(.office-preview ul),
:global(.office-preview ol) {
padding-left: 1.5em;
margin: 0.5em 0;
}
/* ── Shiki code highlighting ─────────────────────────────────── */
.shiki-preview :global(pre.shiki) {
margin: 0;
padding: 0.75rem 1rem;
font-size: 0.75rem;
line-height: 1.6;
border-radius: 0;
overflow-x: auto;
min-height: 100%;
}
.shiki-preview :global(pre.shiki code) {
counter-reset: line;
}
.shiki-preview :global(pre.shiki code > .line) {
counter-increment: line;
display: inline-block;
width: 100%;
white-space: pre;
}
.shiki-preview :global(pre.shiki code > .line::before) {
content: counter(line);
display: inline-block;
width: 2.5em;
text-align: right;
margin-right: 1em;
color: #9ca3af;
user-select: none;
font-size: 0.65rem;
}
:global(.dark) .shiki-preview :global(pre.shiki code > .line::before) {
color: #4b5563;
}
/* Shiki dual-theme: swap CSS variables in dark mode */
:global(.dark) .shiki-preview :global(.shiki),
:global(.dark) .shiki-preview :global(.shiki span) {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
</style>
@@ -0,0 +1,153 @@
<script lang="ts">
export let data: unknown;
export let key: string | null = null;
export let root = true;
export let expandDepth = 2;
let depth = 0;
export { depth };
$: expanded = depth < expandDepth;
const toggle = () => {
expanded = !expanded;
};
$: type = Array.isArray(data) ? 'array' : data === null ? 'null' : typeof data;
$: isExpandable = type === 'object' || type === 'array';
$: entries = isExpandable && data !== null ? Object.entries(data as Record<string, unknown>) : [];
$: bracket = type === 'array' ? ['[', ']'] : ['{', '}'];
$: preview =
type === 'array'
? `[${(data as unknown[]).length}]`
: type === 'object'
? `{${entries.length}}`
: '';
</script>
{#if isExpandable}
<div class="json-node" class:json-root={root}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="json-toggle" on:click={toggle}>
<span class="json-arrow" class:json-expanded={expanded}>▶</span>
{#if key !== null}<span class="json-key">{key}</span><span class="json-colon">: </span>{/if}
{#if !expanded}<span class="json-preview">{bracket[0]} {preview} {bracket[1]}</span>{/if}
{#if expanded}<span class="json-bracket">{bracket[0]}</span>{/if}
</span>
{#if expanded}
<div class="json-children">
{#each entries as [k, v], i}
<div class="json-entry">
<svelte:self
data={v}
key={type === 'array' ? null : k}
root={false}
depth={depth + 1}
{expandDepth}
/>
{#if i < entries.length - 1}<span class="json-comma">,</span>{/if}
</div>
{/each}
</div>
<span class="json-bracket">{bracket[1]}</span>
{/if}
</div>
{:else}
<span class="json-leaf">
{#if key !== null}<span class="json-key">{key}</span><span class="json-colon">: </span>{/if}
{#if type === 'string'}
<span class="json-string">"{data}"</span>
{:else if type === 'number'}
<span class="json-number">{data}</span>
{:else if type === 'boolean'}
<span class="json-boolean">{data}</span>
{:else if type === 'null'}
<span class="json-null">null</span>
{:else}
<span>{String(data)}</span>
{/if}
</span>
{/if}
<style>
.json-root {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.75rem;
line-height: 1.6;
padding: 0.75rem 1rem;
}
.json-node {
/* keep structure visible */
}
.json-toggle {
cursor: pointer;
user-select: none;
}
.json-toggle:hover {
opacity: 0.7;
}
.json-arrow {
display: inline-block;
width: 1em;
font-size: 0.55em;
transition: transform 0.15s ease;
color: #9ca3af;
vertical-align: middle;
}
.json-expanded {
transform: rotate(90deg);
}
.json-children {
padding-left: 1.25em;
border-left: 1px solid rgba(128, 128, 128, 0.15);
margin-left: 0.35em;
}
.json-entry {
/* one entry per line */
}
.json-key {
color: #0550ae;
}
:global(.dark) .json-key {
color: #79c0ff;
}
.json-colon {
color: #6b7280;
}
.json-string {
color: #0a3069;
}
:global(.dark) .json-string {
color: #a5d6ff;
}
.json-number {
color: #0550ae;
}
:global(.dark) .json-number {
color: #79c0ff;
}
.json-boolean {
color: #cf222e;
}
:global(.dark) .json-boolean {
color: #ff7b72;
}
.json-null {
color: #6b7280;
font-style: italic;
}
.json-bracket {
color: #6b7280;
}
.json-comma {
color: #6b7280;
}
.json-preview {
color: #9ca3af;
font-size: 0.85em;
}
:global(.dark) .json-preview {
color: #6b7280;
}
</style>
@@ -0,0 +1,645 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { highlightCode } from '$lib/utils/codeHighlight';
import {
createNotebookSession,
executeNotebookCell,
stopNotebookSession
} from '$lib/apis/terminal';
import Spinner from '../../common/Spinner.svelte';
import Tooltip from '../../common/Tooltip.svelte';
import CellEditor from './CellEditor.svelte';
const i18n = getContext('i18n');
export let notebook: Record<string, unknown>;
export let filePath: string = '';
export let baseUrl: string = '';
export let apiKey: string = '';
interface NotebookCell {
cell_type: 'markdown' | 'code' | 'raw';
source: string[] | string;
outputs?: NotebookOutput[];
execution_count?: number | null;
}
interface NotebookOutput {
output_type: 'stream' | 'execute_result' | 'display_data' | 'error';
text?: string[] | string;
data?: Record<string, string[] | string>;
name?: string;
ename?: string;
evalue?: string;
traceback?: string[];
}
$: cells = (notebook?.cells as NotebookCell[]) ?? [];
$: lang = (notebook?.metadata as Record<string, unknown>)?.kernelspec
? ((notebook.metadata as Record<string, Record<string, string>>).kernelspec?.language ??
'python')
: 'python';
const toStr = (s: string[] | string | undefined): string =>
Array.isArray(s) ? s.join('') : (s ?? '');
// ── Syntax highlighting ──────────────────────────────────────────────
let highlightedCells: Record<number, string> = {};
const highlightCell = async (index: number, code: string, language: string) => {
try {
const { codeToHtml } = await import('shiki');
const html = await codeToHtml(code, {
lang: language,
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: 'light'
});
highlightedCells[index] = html;
highlightedCells = highlightedCells;
} catch {
// fallback handled in template
}
};
$: {
highlightedCells = {};
cells.forEach((cell, i) => {
if (cell.cell_type === 'code' && !editingCell[i]) {
highlightCell(i, toStr(cell.source), lang);
}
});
}
// ── Markdown / output helpers ────────────────────────────────────────
const renderMarkdown = (src: string): string =>
DOMPurify.sanitize(marked.parse(src, { async: false }) as string);
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
const getOutputImages = (output: NotebookOutput): string[] => {
if (!output.data) return [];
const images: string[] = [];
for (const [mime, content] of Object.entries(output.data)) {
if (mime.startsWith('image/')) {
const raw = Array.isArray(content) ? content.join('') : content;
images.push(raw.startsWith('data:') ? raw : `data:${mime};base64,${raw}`);
}
}
return images;
};
const getOutputHtml = (output: NotebookOutput): string | null => {
if (!output.data?.['text/html']) return null;
return DOMPurify.sanitize(toStr(output.data['text/html']));
};
const getOutputText = (output: NotebookOutput): string | null => {
if (output.output_type === 'stream') return toStr(output.text);
if (output.data?.['text/plain']) return toStr(output.data['text/plain']);
return null;
};
// ── Cell editing ─────────────────────────────────────────────────────
let editingCell: Record<number, boolean> = {};
let editedSources: Record<number, string> = {};
const startEditing = (index: number) => {
editingCell[index] = true;
editedSources[index] = toStr(cells[index].source);
editingCell = editingCell;
};
const cancelEditing = (index: number) => {
delete editingCell[index];
delete editedSources[index];
editingCell = editingCell;
highlightCell(index, toStr(cells[index].source), lang);
};
// ── Session / kernel ─────────────────────────────────────────────────
let sessionId: string | null = null;
let kernelReady = false;
let kernelStarting = false;
let kernelError: string | null = null;
let runningCell: number | null = null;
let runAllActive = false;
const canExecute = baseUrl && apiKey && filePath;
const startSession = async (): Promise<boolean> => {
if (!baseUrl || !apiKey || !filePath) return false;
kernelStarting = true;
kernelError = null;
const result = await createNotebookSession(baseUrl, apiKey, filePath);
if ('error' in result) {
kernelStarting = false;
kernelError = result.error;
return false;
}
sessionId = result.id;
kernelReady = true;
kernelStarting = false;
return true;
};
const stopSession = async () => {
if (sessionId && baseUrl && apiKey) {
await stopNotebookSession(baseUrl, apiKey, sessionId);
}
sessionId = null;
kernelReady = false;
};
const runCell = async (index: number) => {
if (runningCell !== null) return;
if (!kernelReady && !(await startSession())) return;
runningCell = index;
const source = editedSources[index] ?? toStr(cells[index].source);
// Apply edits
if (editedSources[index] !== undefined) {
cells[index].source = editedSources[index];
delete editingCell[index];
delete editedSources[index];
editingCell = editingCell;
}
const result = await executeNotebookCell(baseUrl, apiKey, sessionId!, index, source);
if ('error' in result) {
cells[index].outputs = [
{
output_type: 'error',
ename: 'ExecutionError',
evalue: result.error,
traceback: [result.error]
}
];
} else {
cells[index].outputs = result.outputs;
if (result.execution_count !== undefined) {
cells[index].execution_count = result.execution_count;
}
}
cells = cells;
runningCell = null;
highlightCell(index, toStr(cells[index].source), lang);
};
const runAll = async () => {
if (runAllActive) return;
runAllActive = true;
for (let i = 0; i < cells.length; i++) {
if (cells[i].cell_type === 'code') {
await runCell(i);
}
}
runAllActive = false;
};
const autoResize = (e: Event) => {
const ta = e.target as HTMLTextAreaElement;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
};
onDestroy(() => {
if (sessionId) stopSession();
});
</script>
<div class="notebook-view">
<!-- Toolbar -->
{#if baseUrl && apiKey && filePath}
<div class="nb-toolbar flex items-center gap-1 px-2 py-0.5">
<button
class="nb-btn text-[0.6rem]"
on:click={runAll}
disabled={runAllActive || runningCell !== null}
>
{$i18n.t('Run All')}
</button>
{#if kernelReady}
<button
class="nb-btn text-[0.6rem]"
on:click={async () => {
await stopSession();
await startSession();
}}
>
{$i18n.t('Restart')}
</button>
<button class="nb-btn text-[0.6rem]" on:click={stopSession}>
{$i18n.t('Stop')}
</button>
{/if}
<div class="flex-1"></div>
<div class="flex items-center select-none">
{#if kernelStarting}
<Tooltip content={$i18n.t('Starting kernel...')} placement="bottom"
><Spinner className="size-2" /></Tooltip
>
{:else if runningCell !== null}
<Tooltip content={$i18n.t('Running')} placement="bottom"
><Spinner className="size-2" /></Tooltip
>
{:else if kernelReady}
<Tooltip content="Jupyter" placement="bottom"
><span class="size-1.5 rounded-full bg-green-500 inline-block"></span></Tooltip
>
{:else}
<Tooltip content={$i18n.t('No kernel')} placement="bottom"
><span class="size-1.5 rounded-full bg-gray-400 dark:bg-gray-600 inline-block"
></span></Tooltip
>
{/if}
</div>
</div>
{#if kernelError}
<div
class="px-2 py-1 text-[0.65rem] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 whitespace-pre-wrap font-mono"
>
{kernelError}
</div>
{/if}
{/if}
<!-- Cells -->
{#each cells as cell, i}
<div class="nb-cell nb-cell-{cell.cell_type}">
{#if cell.cell_type === 'markdown'}
{#if editingCell[i]}
<textarea
class="nb-edit-textarea text-sm"
bind:value={editedSources[i]}
on:input={autoResize}
on:blur={() => cancelEditing(i)}
on:keydown={(e) => {
if (e.key === 'Escape') cancelEditing(i);
}}
></textarea>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="nb-markdown prose dark:prose-invert max-w-full text-sm cursor-text"
role="textbox"
tabindex="0"
on:dblclick={() => startEditing(i)}
>
{@html renderMarkdown(toStr(cell.source))}
</div>
{/if}
{:else if cell.cell_type === 'code'}
<div class="nb-code-wrap">
<div class="nb-cell-gutter">
{#if runningCell === i}
<div class="nb-cell-label"><Spinner className="size-3" /></div>
{:else if baseUrl && apiKey && filePath}
<button
class="nb-run-btn"
on:click={() => runCell(i)}
disabled={runningCell !== null}
title="Run cell (⌘+Enter)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"
/>
</svg>
</button>
{/if}
<div class="nb-cell-label">
{#if cell.execution_count !== undefined && cell.execution_count !== null}
[{cell.execution_count}]
{:else}
[ ]
{/if}
</div>
</div>
<div class="nb-code-content">
{#if editingCell[i]}
<CellEditor
value={editedSources[i]}
{lang}
on:change={(e) => {
editedSources[i] = e.detail;
}}
on:run={() => runCell(i)}
on:cancel={() => cancelEditing(i)}
/>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="nb-code-source-clickable"
role="textbox"
tabindex="0"
on:dblclick={() => startEditing(i)}
>
{#if highlightedCells[i]}
<div class="nb-code-source shiki-preview">
{@html highlightedCells[i]}
</div>
{:else}
<pre class="nb-code-source-raw">{toStr(cell.source)}</pre>
{/if}
</div>
{/if}
{#if cell.outputs && cell.outputs.length > 0}
<div class="nb-outputs">
{#each cell.outputs as output}
{#if output.output_type === 'error'}
<pre class="nb-error">{stripAnsi(
(output.traceback ?? []).join('\n') || `${output.ename}: ${output.evalue}`
)}</pre>
{:else}
{@const html = getOutputHtml(output)}
{@const images = getOutputImages(output)}
{@const text = getOutputText(output)}
{#if html}
<div class="nb-output-html">{@html html}</div>
{/if}
{#each images as src}
<img {src} alt="Output" class="nb-output-img" />
{/each}
{#if text}
<pre class="nb-output-text">{text}</pre>
{/if}
{/if}
{/each}
</div>
{/if}
</div>
</div>
{:else if cell.cell_type === 'raw'}
<pre class="nb-raw">{toStr(cell.source)}</pre>
{/if}
</div>
{/each}
</div>
<style>
.notebook-view {
padding: 0;
font-size: 0.8rem;
}
.nb-toolbar {
position: sticky;
top: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(4px);
}
:global(.dark) .nb-toolbar {
background: rgba(17, 24, 39, 0.95);
}
.nb-btn {
font-size: 0.6rem;
padding: 0.15rem 0.35rem;
border-radius: 0.25rem;
color: #6b7280;
transition: all 0.15s;
}
.nb-btn:hover:not(:disabled) {
background: rgba(128, 128, 128, 0.1);
color: #374151;
}
:global(.dark) .nb-btn:hover:not(:disabled) {
color: #d1d5db;
}
.nb-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nb-cell {
padding: 0.25rem 0.75rem;
}
.nb-cell-markdown {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.nb-markdown :global(h1) {
font-size: 1.4rem;
}
.nb-markdown :global(h2) {
font-size: 1.2rem;
}
.nb-markdown :global(h3) {
font-size: 1.05rem;
}
.nb-markdown :global(p) {
margin: 0.4em 0;
}
.nb-markdown :global(pre) {
background: rgba(128, 128, 128, 0.06);
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
}
.nb-code-wrap {
display: flex;
gap: 0;
}
.nb-cell-gutter {
flex-shrink: 0;
width: 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 0.35rem;
gap: 0.15rem;
}
.nb-cell-label {
color: #9ca3af;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.6rem;
user-select: none;
text-align: center;
}
:global(.dark) .nb-cell-label {
color: #4b5563;
}
.nb-run-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #9ca3af;
transition: all 0.15s;
}
.nb-run-btn:hover:not(:disabled) {
color: #059669;
background: rgba(5, 150, 105, 0.1);
}
:global(.dark) .nb-run-btn {
color: #6b7280;
}
:global(.dark) .nb-run-btn:hover:not(:disabled) {
color: #34d399;
background: rgba(52, 211, 153, 0.15);
}
.nb-run-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.nb-code-content {
flex: 1;
min-width: 0;
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 4px;
overflow: hidden;
}
:global(.dark) .nb-code-content {
border-color: rgba(128, 128, 128, 0.25);
}
.nb-code-source-clickable {
cursor: text;
}
.nb-code-source :global(pre.shiki) {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
line-height: 1.5;
border-radius: 0;
}
.nb-code-source :global(pre.shiki code > .line::before) {
display: none;
}
.nb-code-source-raw {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
background: #f8f8f8;
color: #1f2937;
}
:global(.dark) .nb-code-source-raw {
background: #161b22;
color: #e6edf3;
}
.nb-code-textarea,
.nb-edit-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
background: #fffef5;
color: #1f2937;
border: none;
outline: none;
resize: none;
min-height: 2.5rem;
overflow: hidden;
}
:global(.dark) .nb-code-textarea,
:global(.dark) .nb-edit-textarea {
background: #1a1d23;
color: #e6edf3;
}
.nb-edit-textarea {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 0.8rem;
background: transparent;
padding: 0.5rem 0.75rem;
border: 1px dashed rgba(128, 128, 128, 0.3);
border-radius: 4px;
}
.nb-outputs {
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
:global(.dark) .nb-outputs {
border-top-color: rgba(128, 128, 128, 0.25);
}
.nb-output-text {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color: #374151;
white-space: pre-wrap;
word-break: break-all;
}
:global(.dark) .nb-output-text {
color: #d1d5db;
}
.nb-output-img {
max-width: 100%;
height: auto;
padding: 0.5rem 0.75rem;
}
.nb-output-html {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
overflow-x: auto;
}
.nb-output-html :global(table) {
border-collapse: collapse;
font-size: 0.7rem;
}
.nb-output-html :global(td),
.nb-output-html :global(th) {
border: 1px solid rgba(128, 128, 128, 0.2);
padding: 3px 8px;
text-align: left;
}
.nb-output-html :global(th) {
background: rgba(128, 128, 128, 0.06);
font-weight: 600;
}
.nb-error {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
background: rgba(239, 68, 68, 0.05);
color: #dc2626;
white-space: pre-wrap;
word-break: break-all;
}
:global(.dark) .nb-error {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
}
.nb-raw {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color: #6b7280;
white-space: pre-wrap;
}
:global(.dark) .nb-code-source :global(.shiki),
:global(.dark) .nb-code-source :global(.shiki span) {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
</style>
@@ -0,0 +1,139 @@
<script lang="ts">
import { onDestroy, getContext } from 'svelte';
import type { ListeningPort } from '$lib/apis/terminal';
import { getListeningPorts, getPortProxyUrl } from '$lib/apis/terminal';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let baseUrl: string;
export let apiKey: string;
let ports: ListeningPort[] = [];
let expanded = false;
let loading = false;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const loadPorts = async () => {
loading = true;
ports = await getListeningPorts(baseUrl, apiKey);
loading = false;
};
const startPolling = () => {
stopPolling();
loadPorts();
pollTimer = setInterval(loadPorts, 5000);
};
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
const openPort = (port: number) => {
const url = getPortProxyUrl(baseUrl, port);
window.open(url, '_blank', 'noopener,noreferrer');
};
// Start polling when baseUrl is available
$: if (baseUrl) {
startPolling();
}
onDestroy(() => {
stopPolling();
});
</script>
<div class="px-2 py-1">
<button
class="flex items-center gap-1 w-full text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => (expanded = !expanded)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 transition-transform {expanded ? '' : '-rotate-90'}"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Ports')}
<span class="ml-auto flex items-center gap-1">
{#if ports.length > 0}
<span
class="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
>
{ports.length}
</span>
{/if}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click|stopPropagation={loadPorts}
aria-label={$i18n.t('Refresh')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 {loading ? 'animate-spin' : ''}"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</span>
</button>
{#if expanded}
<div class="mt-1 space-y-0.5 max-h-[150px] overflow-y-auto">
{#if ports.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500 px-1 py-1">
{$i18n.t('No servers detected')}
</div>
{:else}
{#each ports as port}
<button
class="flex items-center w-full gap-2 px-1.5 py-1 text-xs rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition group"
on:click={() => openPort(port.port)}
>
<span class="font-mono text-blue-500 dark:text-blue-400 shrink-0">
:{port.port}
</span>
<span class="text-gray-500 dark:text-gray-400 truncate flex-1 text-left">
{port.process ?? ''}
</span>
<span
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,373 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import Spinner from '../../common/Spinner.svelte';
const i18n = getContext('i18n');
export let data: ArrayBuffer;
let db: any = null;
let loading = true;
let error: string | null = null;
let tables: string[] = [];
let selectedTable = '';
let columns: string[] = [];
let rows: string[][] = [];
let totalRows = 0;
let page = 0;
const pageSize = 100;
// Custom query
let queryMode = false;
let queryText = '';
let queryColumns: string[] = [];
let queryRows: string[][] = [];
let queryError: string | null = null;
const init = async () => {
try {
const initSqlJs = (await import('sql.js')).default;
const SQL = await initSqlJs({
locateFile: () => '/sql.js/sql-wasm.wasm'
});
db = new SQL.Database(new Uint8Array(data));
// Get table list
const result = db.exec(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
);
if (result.length > 0) {
tables = result[0].values.map((r: string[]) => r[0]);
if (tables.length > 0) {
selectTable(tables[0]);
}
}
loading = false;
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Failed to open database';
}
};
const selectTable = (table: string) => {
if (!db) return;
selectedTable = table;
page = 0;
queryMode = false;
loadPage();
};
const loadPage = () => {
if (!db || !selectedTable) return;
try {
// Get columns
const info = db.exec(`PRAGMA table_info("${selectedTable}")`);
if (info.length > 0) {
columns = info[0].values.map((r: any[]) => r[1] as string);
}
// Get total count
const countResult = db.exec(`SELECT COUNT(*) FROM "${selectedTable}"`);
if (countResult.length > 0) {
totalRows = countResult[0].values[0][0] as number;
}
// Get page data
const offset = page * pageSize;
const result = db.exec(`SELECT * FROM "${selectedTable}" LIMIT ${pageSize} OFFSET ${offset}`);
if (result.length > 0) {
rows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
} else {
rows = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Query failed';
}
};
const runQuery = () => {
if (!db || !queryText.trim()) return;
queryError = null;
try {
const result = db.exec(queryText);
if (result.length > 0) {
queryColumns = result[0].columns;
queryRows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
} else {
queryColumns = [];
queryRows = [];
}
} catch (e) {
queryError = e instanceof Error ? e.message : 'Query failed';
queryColumns = [];
queryRows = [];
}
};
const formatValue = (v: any): string => {
if (v === null) return 'NULL';
if (v instanceof Uint8Array) return `[BLOB ${v.length}B]`;
return String(v);
};
$: totalPages = Math.ceil(totalRows / pageSize);
$: data && init();
onDestroy(() => {
if (db) {
try {
db.close();
} catch {}
db = null;
}
});
</script>
<div class="sqlite-view flex flex-col h-full">
{#if loading}
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
{:else if error}
<div class="p-3 text-xs text-red-500">{error}</div>
{:else}
<!-- Table tabs + query toggle -->
<div
class="flex items-center gap-1 px-2 py-1.5 border-b border-gray-100 dark:border-gray-800 overflow-x-auto scrollbar-none shrink-0"
>
{#each tables as table}
<button
class="shrink-0 px-2 py-1 text-xs rounded-lg transition
{table === selectedTable && !queryMode
? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => selectTable(table)}
>
{table}
</button>
{/each}
<div class="flex-1"></div>
<button
class="shrink-0 px-2 py-1 text-xs rounded-lg transition font-mono
{queryMode
? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => {
queryMode = !queryMode;
}}
>
SQL
</button>
</div>
{#if queryMode}
<!-- Query editor -->
<div class="shrink-0">
<textarea
bind:value={queryText}
placeholder="SELECT * FROM ..."
class="query-editor w-full text-xs font-mono bg-transparent text-gray-800 dark:text-gray-200 px-3 py-2 outline-none resize-none"
rows="3"
spellcheck="false"
on:keydown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
runQuery();
}
}}
></textarea>
<div
class="flex items-center justify-between px-2 py-1 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800"
>
{#if queryError}
<span class="text-[0.65rem] text-red-500 dark:text-red-400 truncate mr-2"
>{queryError}</span
>
{:else}
<span class="text-[0.6rem] text-gray-400 dark:text-gray-600 select-none">⌘+Enter</span>
{/if}
<button
class="shrink-0 px-2.5 py-0.5 text-[0.65rem] font-medium rounded hover:bg-gray-200 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
on:click={runQuery}
>
{$i18n.t('Run')}
</button>
</div>
</div>
{#if queryColumns.length > 0}
<div class="flex-1 overflow-auto min-h-0">
<table class="sqlite-table w-full text-xs font-mono border-collapse">
<thead>
<tr>
<th class="sqlite-row-num">#</th>
{#each queryColumns as col}
<th>{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each queryRows as row, i}
<tr>
<td class="sqlite-row-num">{i + 1}</td>
{#each row as cell}
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else}
<!-- Table data -->
<div class="flex-1 overflow-auto min-h-0">
{#if columns.length > 0}
<table class="sqlite-table w-full text-xs font-mono border-collapse">
<thead>
<tr>
<th class="sqlite-row-num">#</th>
{#each columns as col}
<th>{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row, i}
<tr>
<td class="sqlite-row-num">{page * pageSize + i + 1}</td>
{#each row as cell}
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="text-xs text-gray-400 text-center pt-6">{$i18n.t('No data')}</div>
{/if}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div
class="flex items-center justify-center gap-3 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500 shrink-0"
>
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={page === 0}
on:click={() => {
page--;
loadPage();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<span>{page + 1} / {totalPages} ({totalRows.toLocaleString()} rows)</span>
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={page >= totalPages - 1}
on:click={() => {
page++;
loadPage();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
{/if}
{/if}
</div>
<style>
.sqlite-table {
font-size: 0.7rem;
line-height: 1.4;
}
.sqlite-table th,
.sqlite-table td {
padding: 4px 8px;
text-align: left;
white-space: nowrap;
border: 1px solid rgba(128, 128, 128, 0.15);
}
.sqlite-table thead th {
position: sticky;
top: 0;
background: rgba(243, 244, 246, 0.95);
backdrop-filter: blur(4px);
font-weight: 600;
color: #374151;
border-bottom: 2px solid rgba(128, 128, 128, 0.25);
z-index: 1;
}
:global(.dark) .sqlite-table thead th {
background: rgba(31, 41, 55, 0.95);
color: #d1d5db;
}
.sqlite-table tbody tr:nth-child(even) {
background: rgba(128, 128, 128, 0.04);
}
.sqlite-table tbody tr:hover {
background: rgba(59, 130, 246, 0.06);
}
:global(.dark) .sqlite-table tbody tr:hover {
background: rgba(59, 130, 246, 0.1);
}
.sqlite-table td {
color: #374151;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .sqlite-table td {
color: #d1d5db;
}
.sqlite-row-num {
color: #9ca3af;
font-size: 0.6rem;
text-align: right !important;
user-select: none;
width: 1px;
padding-right: 6px !important;
}
:global(.dark) .sqlite-row-num {
color: #6b7280;
}
.sqlite-null {
color: #9ca3af !important;
font-style: italic;
}
:global(.dark) .sqlite-null {
color: #6b7280 !important;
}
.query-editor::placeholder {
color: #9ca3af;
}
:global(.dark) .query-editor::placeholder {
color: #6b7280;
}
</style>
+55 -46
View File
@@ -508,11 +508,17 @@
let showCodeInterpreterButton = false;
$: showCodeInterpreterButton =
!$selectedTerminalId &&
(atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
codeInterpreterCapableModels.length &&
$config?.features?.enable_code_interpreter &&
($_user.role === 'admin' || $_user?.permissions?.features?.code_interpreter);
// Disable code interpreter when terminal is active (mutually exclusive)
$: if ($selectedTerminalId && codeInterpreterEnabled) {
codeInterpreterEnabled = false;
}
const scrollToBottom = () => {
const element = document.getElementById('messages-container');
element.scrollTo({
@@ -1175,12 +1181,13 @@
<!-- Queued messages display -->
{#if messageQueue.length > 0}
<div
class="mb-1 mx-2 py-0.5 px-1.5 rounded-2xl bg-white dark:bg-gray-900/60 border border-gray-100 dark:border-gray-800/50 overflow-hidden"
class="mb-1 mx-2 py-0.5 px-1.5 rounded-2xl bg-white dark:bg-gray-900/60 border border-gray-100 dark:border-gray-800/50 overflow-x-hidden overflow-y-auto max-h-[25vh]"
>
{#each messageQueue as queuedMessage (queuedMessage.id)}
<QueuedMessageItem
id={queuedMessage.id}
content={queuedMessage.prompt}
files={queuedMessage.files}
onSendNow={onQueueSendNow}
onEdit={onQueueEdit}
onDelete={onQueueDelete}
@@ -1803,59 +1810,61 @@
</Tooltip>
{/if}
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
<!-- Terminal Server Selector -->
{#if ($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url)}
<TerminalMenu bind:show={showTerminalMenu} />
{/if}
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
<button
id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
type="button"
on:click={async () => {
try {
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(
`Permission denied when accessing microphone: {{error}}`,
{
error: err
}
)
);
return null;
});
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true)}
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
<button
id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
type="button"
on:click={async () => {
try {
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(
`Permission denied when accessing microphone: {{error}}`,
{
error: err
}
)
);
return null;
});
if (stream) {
recording = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
if (stream) {
recording = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
stream = null;
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
}}
aria-label="Voice Input"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5 translate-y-[0.5px]"
}}
aria-label="Voice Input"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
</button>
</Tooltip>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5 translate-y-[0.5px]"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
</button>
</Tooltip>
{/if}
{/if}
{#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
@@ -21,6 +21,12 @@
let variableValues = {};
const submitHandler = async () => {
// Normalize Windows CRLF (\r\n) to LF (\n) for all string values
for (const key of Object.keys(variableValues)) {
if (typeof variableValues[key] === 'string') {
variableValues[key] = variableValues[key].replace(/\r\n/g, '\n');
}
}
onSave(variableValues);
show = false;
};
@@ -1,14 +1,17 @@
<script lang="ts">
import { getContext } from 'svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Image from '$lib/components/common/Image.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import EditPencil from '$lib/components/icons/EditPencil.svelte';
import ArrowForward from '$lib/components/icons/ArrowForward.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
export let id: string;
export let content: string;
export let files: any[] = [];
export let onSendNow: (id: string) => void;
export let onEdit: (id: string) => void;
export let onDelete: (id: string) => void;
@@ -21,8 +24,34 @@
</div>
<!-- Message content -->
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-600 dark:text-gray-300 truncate">{content}</p>
<div class="flex-1 min-w-0 flex items-center gap-2">
{#if files.length > 0}
<div class="flex items-center gap-1 shrink-0">
{#each files as file}
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
{@const fileUrl =
file.url?.startsWith('data') || file.url?.startsWith('http')
? file.url
: `${WEBUI_API_BASE_URL}/files/${file.url}${file?.content_type ? '/content' : ''}`}
<Image src={fileUrl} alt="" imageClassName="size-6 rounded-md object-cover" />
{:else}
<div
class="flex items-center px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400"
>
<span class="max-w-[80px] truncate">{file.name ?? 'file'}</span>
</div>
{/if}
{/each}
</div>
{/if}
{#if content}
<p class="text-sm text-gray-600 dark:text-gray-300 truncate">{content}</p>
{:else if files.length === 0}
<p class="text-sm text-gray-400 dark:text-gray-500 truncate italic">
{$i18n.t('Empty message')}
</p>
{/if}
</div>
<!-- Actions -->
+2 -2
View File
@@ -90,11 +90,11 @@
}
visitedMessageIds.add(message.id);
_messages.unshift(message);
_messages.push(message);
message = message.parentId !== null ? history.messages[message.parentId] : null;
}
messages = _messages;
messages = _messages.reverse();
};
// Throttle message list rebuilds to once per animation frame during streaming.
@@ -178,6 +178,9 @@
src="https://www.google.com/s2/favicons?sz=32&domain={citation.source.name}"
alt="favicon"
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-white dark:bg-gray-900"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
{/each}
</div>
@@ -42,6 +42,31 @@
let contentContainerElement;
let floatingButtonsElement;
let sourceIds = [];
$: getSourceIds(sources);
const getSourceIds = (sources) => {
const result = [];
for (const source of sources ?? []) {
for (let index = 0; index < (source.document ?? []).length; index++) {
if (model?.info?.meta?.capabilities?.citations == false) {
result.push('N/A');
continue;
}
const metadata = source.metadata?.[index];
const id = metadata?.source ?? 'N/A';
if (metadata?.name) {
result.push(metadata.name);
} else if (id.startsWith('http://') || id.startsWith('https://')) {
result.push(id);
} else {
result.push(source?.source?.name ?? id);
}
}
}
sourceIds = [...new Set(result)];
};
const updateButtonPosition = (event) => {
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
if (
@@ -142,36 +167,7 @@
{done}
{editCodeBlock}
{topPadding}
sourceIds={(sources ?? []).reduce((acc, source) => {
let ids = [];
source.document.forEach((document, index) => {
if (model?.info?.meta?.capabilities?.citations == false) {
ids.push('N/A');
return ids;
}
const metadata = source.metadata?.[index];
const id = metadata?.source ?? 'N/A';
if (metadata?.name) {
ids.push(metadata.name);
return ids;
}
if (id.startsWith('http://') || id.startsWith('https://')) {
ids.push(id);
} else {
ids.push(source?.source?.name ?? id);
}
return ids;
});
acc = [...acc, ...ids];
// remove duplicates
return acc.filter((item, index) => acc.indexOf(item) === index);
}, [])}
{sourceIds}
{onSourceClick}
{onTaskClick}
{onSave}
@@ -199,7 +195,7 @@
/>
</div>
{#if floatingButtons && model}
{#if floatingButtons}
<FloatingButtons
bind:this={floatingButtonsElement}
{id}
@@ -209,7 +205,7 @@
? model?.id
: (selectedModels ?? []).length > 0
? selectedModels.at(0)
: model?.id}
: (model?.id ?? null)}
messages={createMessagesList(history, messageId)}
onAdd={({ modelId, parentId, messages }) => {
console.log(modelId, parentId, messages);
@@ -36,6 +36,8 @@
let tokens = [];
let pendingUpdate = null;
let lastContent = '';
let lastParsedContent = '';
const options = {
throwOnError: false,
@@ -56,24 +58,35 @@
});
const parseTokens = () => {
tokens = marked.lexer(replaceTokens(processResponseContent(content), model?.name, $user?.name));
if (content === lastContent) return;
lastContent = content;
const processed = replaceTokens(processResponseContent(content), model?.name, $user?.name);
if (processed === lastParsedContent) return;
lastParsedContent = processed;
tokens = marked.lexer(processed);
};
// Throttle parsing to once per animation frame while streaming
$: if (content) {
if (done) {
cancelAnimationFrame(pendingUpdate);
pendingUpdate = null;
parseTokens();
} else if (!pendingUpdate) {
pendingUpdate = requestAnimationFrame(() => {
const updateHandler = (content) => {
if (content) {
if (done) {
cancelAnimationFrame(pendingUpdate);
pendingUpdate = null;
parseTokens();
});
} else if (!pendingUpdate) {
pendingUpdate = requestAnimationFrame(() => {
pendingUpdate = null;
parseTokens();
});
}
}
}
};
onDestroy(() => {
$: updateHandler(content);
// Throttle parsing to once per animation frame while streaming
$: onDestroy(() => {
cancelAnimationFrame(pendingUpdate);
});
</script>
+2 -2
View File
@@ -25,7 +25,7 @@
<div class="flex w-full">
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${data.user.id}/profile/image`}
className={'size-5 -translate-y-[1px]'}
className={'size-5 -translate-y-[1px] flex-shrink-0'}
/>
<div class="ml-2">
<div class=" flex justify-between items-center">
@@ -45,7 +45,7 @@
<div class="flex w-full">
<ProfileImage
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${data.model?.id ?? data.message.model}&lang=${$i18n.language}`}
className={'size-5 -translate-y-[1px]'}
className={'size-5 -translate-y-[1px] flex-shrink-0'}
/>
<div class="ml-2">
@@ -0,0 +1,471 @@
<script context="module">
let savedPyodidePath = '/mnt/uploads';
</script>
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import { pyodideWorker } from '$lib/stores';
import PyodideWorkerConstructor from '$lib/workers/pyodide.worker?worker';
import type { FileEntry } from '$lib/apis/terminal';
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte';
import FilePreview from './FileNav/FilePreview.svelte';
import ConfirmDialog from '../common/ConfirmDialog.svelte';
import Spinner from '../common/Spinner.svelte';
import Folder from '../icons/Folder.svelte';
import Document from '../icons/Document.svelte';
const i18n = getContext('i18n');
export let overlay = false;
// ── State ─────────────────────────────────────────────────────────────
let currentPath = savedPyodidePath;
let entries: FileEntry[] = [];
let loading = false;
let error: string | null = null;
let selectedFile: string | null = null;
let fileLoading = false;
let fileContent: string | null = null;
let fileImageUrl: string | null = null;
let isDragOver = false;
let showDeleteConfirm = false;
let deletePath = '';
let deleteName = '';
let creatingFolder = false;
let newFolderName = '';
let newFolderInput: HTMLInputElement;
let creatingFile = false;
let newFileName = '';
let newFileInput: HTMLInputElement;
let _reqId = 0;
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
// ── Worker management ─────────────────────────────────────────────────
function ensureWorker(): Worker {
let worker = $pyodideWorker;
if (!worker) {
worker = new PyodideWorkerConstructor();
pyodideWorker.set(worker);
}
return worker;
}
function sendWorkerMessage(msg: any): Promise<any> {
const worker = ensureWorker();
const id = `fs-${++_reqId}`;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
worker.removeEventListener('message', handler);
reject('Timeout');
}, 30000);
function handler(event: MessageEvent) {
if (event.data?.id !== id) return;
clearTimeout(timeout);
worker.removeEventListener('message', handler);
resolve(event.data);
}
worker.addEventListener('message', handler);
worker.postMessage({ ...msg, id });
});
}
// ── Breadcrumbs ───────────────────────────────────────────────────────
const buildBreadcrumbs = (path: string) => {
const parts = path.split('/').filter(Boolean);
return parts.reduce(
(acc, part) => {
const prev = acc[acc.length - 1];
acc.push({ label: part, path: `${prev.path}${part}/` });
return acc;
},
[{ label: '/', path: '/' }]
);
};
$: breadcrumbs = buildBreadcrumbs(currentPath);
// ── Operations ────────────────────────────────────────────────────────
const loadDir = async (path: string) => {
loading = true;
error = null;
selectedFile = null;
clearPreview();
currentPath = path.endsWith('/') ? path : path + '/';
savedPyodidePath = currentPath;
try {
const res = await sendWorkerMessage({
type: 'fs:list',
path: currentPath.replace(/\/$/, '') || '/'
});
entries = (res.entries || []).sort((a: FileEntry, b: FileEntry) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
} catch {
error = 'Failed to list directory';
entries = [];
}
loading = false;
};
const openEntry = async (entry: FileEntry) => {
if (entry.type === 'directory') {
await loadDir(`${currentPath}${entry.name}/`);
return;
}
const filePath = `${currentPath}${entry.name}`;
selectedFile = filePath;
fileLoading = true;
clearPreview();
try {
const res = await sendWorkerMessage({ type: 'fs:read', path: filePath });
if (res.error) {
fileContent = `Error: ${res.error}`;
} else if (isImage(filePath)) {
const blob = new Blob([res.data]);
fileImageUrl = URL.createObjectURL(blob);
} else {
const decoder = new TextDecoder('utf-8', { fatal: true });
try {
fileContent = decoder.decode(res.data);
} catch {
fileContent = `[Binary file: ${entry.size ?? 0} bytes]`;
}
}
} catch {
fileContent = 'Failed to read file';
}
fileLoading = false;
};
const clearPreview = () => {
fileContent = null;
if (fileImageUrl) {
URL.revokeObjectURL(fileImageUrl);
fileImageUrl = null;
}
};
const downloadFile = async (path: string) => {
try {
const res = await sendWorkerMessage({ type: 'fs:read', path });
if (res.data) {
const blob = new Blob([res.data]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = path.split('/').pop() ?? 'file';
a.click();
URL.revokeObjectURL(url);
}
} catch (e) {
console.error('Download failed:', e);
}
};
const confirmDelete = (path: string, name: string) => {
deletePath = path;
deleteName = name;
showDeleteConfirm = true;
};
const doDelete = async () => {
try {
await sendWorkerMessage({ type: 'fs:delete', path: deletePath });
if (selectedFile === deletePath) {
selectedFile = null;
clearPreview();
}
await loadDir(currentPath);
} catch (e) {
console.error('Delete failed:', e);
}
};
const startNewFolder = async () => {
creatingFolder = true;
newFolderName = '';
await tick();
newFolderInput?.focus();
};
const submitNewFolder = async () => {
const name = newFolderName.trim();
creatingFolder = false;
newFolderName = '';
if (!name) return;
const folderPath = `${currentPath}${name}`.replace(/\/$/, '');
try {
await sendWorkerMessage({ type: 'fs:mkdir', path: folderPath });
await loadDir(currentPath);
} catch (e) {
console.error('Failed to create folder:', e);
}
};
const startNewFile = async () => {
creatingFile = true;
newFileName = '';
await tick();
newFileInput?.focus();
};
const submitNewFile = async () => {
const name = newFileName.trim();
creatingFile = false;
newFileName = '';
if (!name) return;
try {
await sendWorkerMessage({
type: 'fs:upload',
files: [{ name, data: new ArrayBuffer(0) }],
dir: currentPath.replace(/\/$/, '') || '/'
});
await loadDir(currentPath);
} catch (e) {
console.error('Failed to create file:', e);
}
};
const uploadFiles = async (fileList: File[]) => {
const payloads: { name: string; data: ArrayBuffer }[] = [];
for (const file of fileList) {
payloads.push({ name: file.name, data: await file.arrayBuffer() });
}
try {
await sendWorkerMessage({
type: 'fs:upload',
files: payloads,
dir: currentPath.replace(/\/$/, '') || '/'
});
} catch (e) {
console.error('Upload failed:', e);
}
await loadDir(currentPath);
};
// ── Drag and drop ─────────────────────────────────────────────────────
const handleDragOver = (e: DragEvent) => {
if (selectedFile) return;
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
e.stopPropagation();
isDragOver = true;
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
isDragOver = false;
if (selectedFile) return;
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
if (droppedFiles.length) await uploadFiles(droppedFiles);
};
// ── Lifecycle ─────────────────────────────────────────────────────────
onMount(() => {
ensureWorker();
loadDir(currentPath);
});
</script>
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete {{name}}', { name: deleteName })}
message={$i18n.t('Are you sure you want to delete this?')}
on:confirm={doDelete}
/>
<div
class="flex flex-col h-full min-h-0 min-w-0 relative"
on:dragover={handleDragOver}
on:dragleave={() => (isDragOver = false)}
on:drop={handleDrop}
role="region"
aria-label={$i18n.t('Pyodide file browser')}
>
{#if isDragOver}
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 dark:bg-gray-850/80 backdrop-blur-sm pointer-events-none gap-1.5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-5 text-gray-400 dark:text-gray-500"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
/>
</svg>
<span class="text-xs text-gray-400 dark:text-gray-500">{$i18n.t('Drop files here')}</span>
</div>
{/if}
{#if overlay}
<div class="absolute inset-0 z-10 pointer-events-none" />
{/if}
<!-- Toolbar (shared with FileNav) -->
<FileNavToolbar
{breadcrumbs}
{selectedFile}
{loading}
onNavigate={(path) => loadDir(path)}
onRefresh={() => {
if (selectedFile) {
const name = selectedFile.split('/').pop() ?? '';
openEntry({ name, type: 'file', size: 0 });
} else {
loadDir(currentPath);
}
}}
onNewFolder={startNewFolder}
onNewFile={startNewFile}
onUploadFiles={uploadFiles}
onMove={() => {}}
>
<!-- File action buttons when a file is selected (slot content) -->
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => selectedFile && downloadFile(selectedFile)}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
</button>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400"
on:click={() => {
if (selectedFile) {
confirmDelete(selectedFile, selectedFile.split('/').pop() ?? '');
}
}}
aria-label={$i18n.t('Delete')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022 1.005 11.36A2.75 2.75 0 0 0 7.764 20h4.472a2.75 2.75 0 0 0 2.745-2.689l1.005-11.36.149.022a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 1 .7.8l-.3 6a.75.75 0 0 1-1.5-.075l.3-6a.75.75 0 0 1 .8-.725ZM12.2 8.52a.75.75 0 0 0-1.5.075l-.3 6a.75.75 0 0 0 1.5-.075l.3-6Z"
clip-rule="evenodd"
/>
</svg>
</button>
</FileNavToolbar>
<!-- Content area -->
<div class="flex-1 min-h-0 flex flex-col">
{#if selectedFile}
<FilePreview {selectedFile} {fileLoading} {fileImageUrl} {fileContent} {overlay} />
{:else if loading}
<div class="flex items-center justify-center flex-1 p-6">
<Spinner className="size-4" />
</div>
{:else if error}
<div class="flex items-center justify-center flex-1 p-6">
<div class="text-xs text-red-500">{error}</div>
</div>
{:else if entries.length === 0 && !creatingFolder && !creatingFile}
<div class="flex flex-col items-center justify-center flex-1 p-6 text-center gap-2">
<Folder className="size-5 text-gray-300 dark:text-gray-600" />
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No files yet. Upload files or run Python code to create them.')}
</div>
</div>
{/if}
{#if !loading && !error && !selectedFile}
{#if creatingFolder}
<div class="flex items-center gap-2 px-3 py-1.5">
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
<input
bind:this={newFolderInput}
bind:value={newFolderName}
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500"
placeholder={$i18n.t('Folder name')}
on:keydown={(e) => {
if (e.key === 'Enter') submitNewFolder();
if (e.key === 'Escape') {
creatingFolder = false;
newFolderName = '';
}
}}
on:blur={submitNewFolder}
/>
</div>
{/if}
{#if creatingFile}
<div class="flex items-center gap-2 px-3 py-1.5">
<Document className="size-4 shrink-0 text-gray-400 dark:text-gray-500" />
<input
bind:this={newFileInput}
bind:value={newFileName}
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500"
placeholder={$i18n.t('File name')}
on:keydown={(e) => {
if (e.key === 'Enter') submitNewFile();
if (e.key === 'Escape') {
creatingFile = false;
newFileName = '';
}
}}
on:blur={submitNewFile}
/>
</div>
{/if}
{#if entries.length > 0 || creatingFolder || creatingFile}
<ul class="overflow-y-auto flex-1 min-h-0">
{#each entries as entry (entry.name)}
<FileEntryRow
{entry}
{currentPath}
onOpen={openEntry}
onDownload={downloadFile}
onDelete={confirmDelete}
/>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
@@ -76,7 +76,7 @@
<div class="flex justify-end pt-1 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 {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"
@@ -85,9 +85,9 @@
{$i18n.t('Add')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
@@ -86,7 +86,7 @@
<div class="flex justify-end pt-1 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 {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"
@@ -95,9 +95,9 @@
{$i18n.t('Update')}
{#if loading}
<div class="ml-2 self-center">
<span class="shrink-0">
<Spinner />
</div>
</span>
{/if}
</button>
</div>
+16 -2
View File
@@ -20,6 +20,7 @@
export let connected = false;
export let connecting = false;
let resizeObserver: ResizeObserver | null = null;
let pingInterval: ReturnType<typeof setInterval> | null = null;
// Resolve the active terminal server's info for the WebSocket URL
const getTerminalInfo = (): { serverId: string; baseUrl: string } | null => {
@@ -108,6 +109,13 @@
if (term && ws) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
// Keepalive ping to prevent idle timeout from proxies/LBs
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25000);
};
ws.onmessage = (event) => {
@@ -141,6 +149,10 @@
};
const disconnect = () => {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (ws) {
ws.close();
ws = null;
@@ -229,8 +241,10 @@
});
resizeObserver.observe(terminalEl);
// Auto-connect
connect();
// Connection is handled by the reactive block below (which fires
// when `term` is set here), so we intentionally do NOT call
// connect() to avoid creating a duplicate WebSocket whose onclose
// handler would write a spurious "[Connection closed]" message.
};
// Reconnect when the selected terminal changes
+135 -15
View File
@@ -40,6 +40,8 @@
let isAudio = false;
let isImage = false;
let isExcel = false;
let isDocx = false;
let isPptx = false;
let selectedTab = '';
let excelWorkbook: WorkBook | null = null;
@@ -49,6 +51,15 @@
let excelError = '';
let rowCount = 0;
// DOCX state
let docxHtml = '';
let docxError = '';
// PPTX state
let pptxSlides: string[] = [];
let pptxCurrentSlide = 0;
let pptxError = '';
let pzInstance: PanZoom | null = null;
const initImagePanzoom = (node: HTMLElement) => {
@@ -128,6 +139,16 @@
item.name.toLowerCase().endsWith('.xlsx') ||
item.name.toLowerCase().endsWith('.csv')));
$: isDocx =
item?.meta?.content_type ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
(item?.name && item.name.toLowerCase().endsWith('.docx'));
$: isPptx =
item?.meta?.content_type ===
'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
(item?.name && item.name.toLowerCase().endsWith('.pptx'));
const loadExcelContent = async () => {
try {
excelError = '';
@@ -150,26 +171,48 @@
const renderExcelSheet = async () => {
if (!excelWorkbook || !selectedSheet) return;
const { excelToTable } = await import('$lib/utils/excelToTable');
const worksheet = excelWorkbook.Sheets[selectedSheet];
// Calculate row count
const XLSX = await import('xlsx');
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
rowCount = range.e.r - range.s.r + 1;
excelHtml = DOMPurify.sanitize(
XLSX.utils.sheet_to_html(worksheet, {
id: 'excel-table',
editable: false,
header: ''
})
);
const result = await excelToTable(worksheet);
excelHtml = result.html;
rowCount = result.rowCount;
};
$: if (selectedSheet && excelWorkbook) {
renderExcelSheet();
}
const loadDocxContent = async () => {
try {
docxError = '';
const [arrayBuffer, mammoth] = await Promise.all([
getFileContentById(item.id),
import('mammoth')
]);
const result = await mammoth.convertToHtml({ arrayBuffer });
docxHtml = DOMPurify.sanitize(result.value);
} catch (error) {
console.error('Error loading DOCX file:', error);
docxError = $i18n.t('Failed to load DOCX file. Please try downloading it instead.');
}
};
const loadPptxContent = async () => {
try {
pptxError = '';
const [arrayBuffer, { pptxToImages }] = await Promise.all([
getFileContentById(item.id),
import('$lib/utils/pptxToHtml')
]);
const result = await pptxToImages(arrayBuffer);
pptxSlides = result.images;
pptxCurrentSlide = 0;
} catch (error) {
console.error('Error loading PPTX file:', error);
pptxError = $i18n.t('Failed to load PPTX file. Please try downloading it instead.');
}
};
const loadContent = async () => {
selectedTab = '';
expandedContent = false;
@@ -201,6 +244,12 @@
if (isExcel) {
await loadExcelContent();
}
if (isDocx) {
await loadDocxContent();
}
if (isPptx) {
await loadPptxContent();
}
loading = false;
}
@@ -358,7 +407,7 @@
</div>
{/if}
{#if isAudio || isPDF || isExcel || isCode || isMarkdown}
{#if isAudio || isPDF || isExcel || isCode || isMarkdown || isDocx || isPptx}
<div
class="flex mb-2.5 scrollbar-none overflow-x-auto w-full border-b border-gray-50 dark:border-gray-850/30 text-center text-sm font-medium bg-transparent dark:text-gray-200"
>
@@ -510,7 +559,7 @@
{/if}
{#if excelHtml}
<div class="excel-table-container overflow-auto max-h-[60vh]">
<div class="office-preview overflow-auto max-h-[60vh]">
{@html excelHtml}
</div>
{:else}
@@ -534,6 +583,77 @@
>
<Markdown content={item.file.data.content} id="markdown-viewer" />
</div>
{:else if isDocx}
{#if docxError}
<div class="text-red-500 text-sm p-4">{docxError}</div>
{:else if docxHtml}
<div
class="office-preview max-h-[60vh] overflow-auto p-4 prose dark:prose-invert max-w-full text-sm"
>
{@html docxHtml}
</div>
{:else}
<div class="text-gray-500 text-sm p-4">No content available</div>
{/if}
{:else if isPptx}
{#if pptxError}
<div class="text-red-500 text-sm p-4">{pptxError}</div>
{:else if pptxSlides.length > 0}
<div class="max-h-[60vh] overflow-auto">
<div class="flex justify-center p-4">
<img
src={pptxSlides[pptxCurrentSlide]}
alt="Slide {pptxCurrentSlide + 1}"
class="max-w-full max-h-[50vh] object-contain rounded-md shadow-lg"
draggable="false"
/>
</div>
{#if pptxSlides.length > 1}
<div class="flex items-center justify-center gap-3 pb-3 text-sm text-gray-500">
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={pptxCurrentSlide === 0}
on:click={() => (pptxCurrentSlide = Math.max(0, pptxCurrentSlide - 1))}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<span>{pptxCurrentSlide + 1} / {pptxSlides.length}</span>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={pptxCurrentSlide === pptxSlides.length - 1}
on:click={() =>
(pptxCurrentSlide = Math.min(pptxSlides.length - 1, pptxCurrentSlide + 1))}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
{:else}
<div class="text-gray-500 text-sm p-4">No content available</div>
{/if}
{:else}
<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
{(item?.file?.data?.content ?? '').trim() || 'No content'}
+33 -2
View File
@@ -114,7 +114,7 @@
import { Fragment, DOMParser } from 'prosemirror-model';
import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor, Extension, mergeAttributes } from '@tiptap/core';
import { Editor, Extension, markInputRule, mergeAttributes } from '@tiptap/core';
import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
@@ -135,8 +135,26 @@
import FileHandler from '@tiptap/extension-file-handler';
import Typography from '@tiptap/extension-typography';
import Highlight from '@tiptap/extension-highlight';
import Code from '@tiptap/extension-code';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
// WORKAROUND: TipTap's default Code mark input rule regex captures the
// character before the opening backtick, causing it to be deleted.
// This uses a lookbehind assertion instead so the preceding character is
// matched for position but not captured/deleted.
// Upstream fix: https://github.com/ueberdosis/tiptap/pull/7124
const backtickInputRegex = /(?<=\s|^)`([^`]+)`(?!`)$/;
const FixedCode = Code.extend({
addInputRules() {
return [
markInputRule({
find: backtickInputRegex,
type: this.type
})
];
}
});
import Mention from '@tiptap/extension-mention';
import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
@@ -467,6 +485,17 @@
focus();
};
// Convert text to ProseMirror nodes, using hardBreak for newlines
const textToNodes = (state, text) => {
if (!text.includes('\n')) return state.schema.text(text);
const nodes = [];
text.split('\n').forEach((line, i) => {
if (i > 0) nodes.push(state.schema.nodes.hardBreak.create());
if (line) nodes.push(state.schema.text(line));
});
return nodes;
};
export const replaceVariables = (variables) => {
if (!editor || !editor.view) return;
const { state, view } = editor;
@@ -501,7 +530,7 @@
// Apply replacements in reverse order to maintain correct positions
replacements.reverse().forEach(({ from, to, text }) => {
tr = tr.replaceWith(from, to, text !== '' ? state.schema.text(text) : []);
tr = tr.replaceWith(from, to, text !== '' ? textToNodes(state, text) : []);
});
// Only dispatch if there are changes
@@ -692,12 +721,14 @@
extensions: [
StarterKit.configure({
link: link,
code: false, // Disabled in favor of FixedCode (see workaround above)
// When rich text is off, disable Strike from StarterKit so we can
// re-add it below without its Mod-Shift-s shortcut (which conflicts
// with the Toggle Sidebar shortcut). When rich text is on, the user
// can undo strikethrough via the toolbar, so the shortcut is fine.
...(richText ? {} : { strike: false })
}),
FixedCode,
...(dragHandle ? [ListItemDragHandle] : []),
Placeholder.configure({ placeholder: () => _placeholder, showOnlyWhenEditable: false }),
SelectionDecoration,
+9 -6
View File
@@ -142,19 +142,20 @@
}
};
const createFolder = async ({ name, data }) => {
const createFolder = async ({ name, data, parent_id }) => {
name = name?.trim();
if (!name) {
toast.error($i18n.t('Folder name cannot be empty.'));
return;
}
const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null);
if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
// Check for duplicate names in the same parent
const siblings = Object.values(folders).filter((folder) => folder.parent_id === parent_id);
if (siblings.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
// If a folder with the same name already exists, append a number to the name
let i = 1;
while (
rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
siblings.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
) {
i++;
}
@@ -166,9 +167,10 @@
const tempId = uuidv4();
folders = {
...folders,
tempId: {
[tempId]: {
id: tempId,
name: name,
parent_id: parent_id,
created_at: Date.now(),
updated_at: Date.now()
}
@@ -176,7 +178,8 @@
const res = await createNewFolder(localStorage.token, {
name,
data
data,
parent_id
}).catch((error) => {
toast.error(`${error}`);
return null;
@@ -78,7 +78,6 @@
let chat = null;
let mouseOver = false;
let draggable = false;
const loadChat = async () => {
if (!chat) {
@@ -154,8 +153,14 @@
};
const archiveChatHandler = async (id) => {
await archiveChatById(localStorage.token, id);
dispatch('change');
try {
await archiveChatById(localStorage.token, id);
dispatch('change');
toast.success($i18n.t('Chat archived.'));
} catch (error) {
console.error('Error archiving chat:', error);
toast.error($i18n.t('Failed to archive chat.'));
}
};
const moveChatHandler = async (chatId, folderId) => {
@@ -366,7 +371,7 @@
id="sidebar-chat-group"
bind:this={itemElement}
class=" w-full {className} relative group"
draggable={draggable && !confirmEdit}
draggable={!confirmEdit}
>
{#if confirmEdit}
<div

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