diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a9dee71c..259f2f61ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,85 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.9] - 2026-03-07 + +### Added + +- ▶️ **Open Terminal notebook cell execution.** Users can now run Jupyter Notebook code cells directly in the Open Terminal file navigator, execute entire notebooks with a single click, edit and modify cells before running, and control the kernel - bringing full interactive notebook execution to the browser. [Commit](https://github.com/open-webui/open-webui/commit/4b3ed3e802d6f2ec8ee7caf358af810b7d09f789) +- 🗃️ **Open Terminal SQLite browser.** Users can now browse SQLite database files directly in the Open Terminal file navigator, viewing tables and running queries without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/a181b4a731a9ec7856be08d0b045a454d1341cf4) +- 📉 **Open Terminal Mermaid diagram rendering.** Markdown files with Mermaid code blocks are now rendered as diagrams directly in the Open Terminal file navigator, making it easier to visualize flowcharts and other diagrams. [Commit](https://github.com/open-webui/open-webui/commit/aaa49bdd6d6e5c10e8be554039d3cac673008fc2) +- 📓 **Open Terminal Jupyter Notebook previews.** Users can now preview Jupyter Notebook files directly in the Open Terminal file navigator, making it easier to view notebook content without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/b081e33c0a37585a1ee60b6e0e1ea03457f1e5f4) +- 🔃 **Open Terminal auto-refresh.** The Open Terminal file navigator now automatically refreshes when the model writes or modifies files, keeping the view in sync without manual refresh. [Commit](https://github.com/open-webui/open-webui/commit/828656b35f04bf486609183799cf8aa2e9850a76) +- 📎 **Open Terminal file copy button.** Users can now copy file contents directly to clipboard in the Open Terminal file navigator with a single click, making it easier to quickly grab file content without downloading. [Commit](https://github.com/open-webui/open-webui/commit/f5ea1ce250cb02fbc583c6cb3f52a923912d0178) +- 💻 **Code syntax highlighting and XLSX improvements in Open Terminal.** Code files now display with syntax highlighting in the Open Terminal file navigator, and XLSX spreadsheets now show column headers and row numbers for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/f962bae98306ea9264967b78b803397f4821f9b0) +- 🌳 **Open Terminal JSON tree view.** JSON, JSONC, JSONL, and JSON5 files now display as interactive collapsible tree views in the Open Terminal file navigator, and SVG files render as preview images with syntax highlighting support. [Commit](https://github.com/open-webui/open-webui/commit/f4c38e6001dd9d4853ed923e0bc5e790c4fd9941) +- 🛜 **Open Terminal port viewing.** Users can now view listening ports in the Open Terminal file navigator and open proxy connections to them directly from the UI. [Commit](https://github.com/open-webui/open-webui/commit/e08341dab3bb10e26a64eb44cbebd2d507087b03) +- 🎬 **Open Terminal video previews.** Users can now preview video and audio files directly in the Open Terminal file navigator, making it easier to view media without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/c40f26946f2eaeb1587a1f8b0c643b4a5121fc06) +- ✏️ **Open Terminal HTML editing.** Users can now edit HTML source files in Open Terminal with CodeMirror editor, and the save button is properly hidden in preview mode. [Commit](https://github.com/open-webui/open-webui/commit/7806cd5aef9fb0505b2c642ef70599a403cf14ba) +- 📄 **Open Terminal DOCX preview.** Word documents generated or modified by the AI can now be viewed directly in the file navigator with formatted text, tables, and images rendered inline — no need to download and open in a separate application. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34) +- 📊 **Open Terminal XLSX preview.** Excel spreadsheets in the file navigator now render as interactive tables with column headers and row numbers, making it easy to verify data the AI has generated or processed. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34) +- 📽️ **Open Terminal PPTX preview.** PowerPoint presentations created by the AI can now be viewed slide-by-slide directly in the file navigator, enabling quick review and iteration without leaving the browser. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34) +- 📁 **Pyodide file system support.** Users can now upload files for Python code execution in the code interpreter. Uploaded files are available in the `/mnt/uploads/` directory, and code can write output files there for download. The file system persists across code executions within the same session. The code interpreter now also informs models that pip install is not available in the Pyodide environment, guiding them to use alternative approaches with available modules. [#3583](https://github.com/open-webui/open-webui/issues/3583), [Commit](https://github.com/open-webui/open-webui/commit/ce0ca894fea8a2904bc6f832ff186d5fe53dd0b9), [Commit](https://github.com/open-webui/open-webui/commit/989938856fdb4b4afa584ae2d18c88d5be614ae2) +- 🧰 **Tool files access.** Tools can now access the files from the current chat context via the files property in their metadata, enabling more powerful tool integrations. [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0) +- ⚡ **Chat performance.** Chat messages now load and display significantly faster thanks to optimized markdown rendering, eliminating delays when viewing messages with mathematical expressions. [#22196](https://github.com/open-webui/open-webui/pull/22196), [#20878](https://github.com/open-webui/open-webui/discussions/20878) +- 📜 **Message list performance.** Improved message list rendering performance by optimizing array operations, reducing complexity from O(n²) to O(n). [#22280](https://github.com/open-webui/open-webui/pull/22280) +- 🧵 **Streaming markdown performance.** Improved chat responsiveness during streaming by skipping unnecessary markdown re-parsing when the content hasn't changed, eliminating wasted processing during model pauses. [#22183](https://github.com/open-webui/open-webui/pull/22183) +- 🏃 **Chat streaming performance.** Chat streaming is now faster for users not using the voice call feature by skipping unnecessary text parsing that was running on every token. [#22195](https://github.com/open-webui/open-webui/pull/22195) +- 🔖 **Source list performance.** Source lists in chat now render faster thanks to optimized computation that avoids unnecessary recalculations, including moving sourceIds computation to a reactive variable. [#22279](https://github.com/open-webui/open-webui/pull/22279), [Commit](https://github.com/open-webui/open-webui/commit/88af78c), [Commit](https://github.com/open-webui/open-webui/commit/339ed1d72e100c89d8eb26de761dfefe842ef90c) +- 💨 **Chat message tree operations.** Chat message tree operations are now significantly faster, improving overall chat responsiveness. [#22194](https://github.com/open-webui/open-webui/pull/22194) +- 🚀 **Initial page load speed.** Page load is now significantly faster thanks to deferred loading of the syntax highlighting library, reducing the initial JavaScript bundle by several megabytes. [#22304](https://github.com/open-webui/open-webui/pull/22304) +- 🗓️ **Action priority query optimization.** Improved performance of action priority resolution by fixing an N+1 query pattern, reducing database round-trips when loading model actions. [#22301](https://github.com/open-webui/open-webui/pull/22301) +- 🔑 **API key middleware optimization.** The API key restriction middleware was converted to a pure ASGI middleware for improved streaming performance, removing per-chunk call overhead. [#22188](https://github.com/open-webui/open-webui/pull/22188) +- 🏎️ **Model list loading performance.** Model lists now load significantly faster thanks to optimized custom model matching that uses dictionary lookups instead of nested loops. [#22299](https://github.com/open-webui/open-webui/pull/22299), [Commit](https://github.com/open-webui/open-webui/commit/29160741a3defa8768a43100cb6e63c56400279c), [Commit](https://github.com/open-webui/open-webui/commit/03c6caac1fc8625f85cf1164f5a977be8005c1bc) +- ⏱️ **Event call timeout configuration.** Administrators can now configure the WebSocket event call timeout via the WEBSOCKET_EVENT_CALLER_TIMEOUT environment variable, giving users more time to respond to event_call forms instead of timing out after 60 seconds. [#22222](https://github.com/open-webui/open-webui/pull/22222), [#22220](https://github.com/open-webui/open-webui/issues/22220) +- 🔁 **File refresh button visibility.** The refresh button in the chat file navigator now appears when viewing files as well as directories, allowing users to refresh the file view at any time. [Commit](https://github.com/open-webui/open-webui/commit/49a2e5bf573415dae6d4c7e5bd635e499c8de77a) +- 📂 **Nested folders support.** Users can now create subfolders within parent folders, improving organization of chats. A new "Create Subfolder" option is available in the folder context menu. [#22073](https://github.com/open-webui/open-webui/pull/22073), [Commit](https://github.com/open-webui/open-webui/commit/8913f37c3d8fde7dea6d54a550357f1d495b3941) +- 🔔 **Banner loading on navigation.** Admin-configured banners now load when navigating to the homepage, not just on page refresh, ensuring users see new banners immediately. [#22340](https://github.com/open-webui/open-webui/pull/22340), [#22180](https://github.com/open-webui/open-webui/issues/22180) +- 📡 **System metrics via OpenTelemetry.** Administrators can now monitor Python runtime and system metrics including CPU, memory, garbage collection, and thread counts through the existing OpenTelemetry pipeline. [#22265](https://github.com/open-webui/open-webui/pull/22265) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 Translations for French, Finnish, Turkish, German, Simplified Chinese, and Traditional Chinese were enhanced and expanded. +- 🔍 **Web search tool guidance.** The web search tool description was updated to encourage direct usage without first checking knowledge bases, making it clearer for users who want to search the web immediately. [#22264](https://github.com/open-webui/open-webui/pull/22264) + +### Fixed + +- 🗄️ **Migration memory usage.** Database migration on large deployments now processes messages in batches instead of loading everything into memory, preventing out-of-memory errors during upgrades. [#21542](https://github.com/open-webui/open-webui/pull/21542), [#21539](https://github.com/open-webui/open-webui/discussions/21539) +- 🔒 **SQLCipher connection stability.** Fixed a crash that occurred when using database encryption with SQLCipher by changing the default connection pool behavior, ensuring stable operation during multi-threaded operations like user signup. [#22273](https://github.com/open-webui/open-webui/pull/22273), [#22258](https://github.com/open-webui/open-webui/issues/22258) +- 🛑 **Stop sequence error.** Fixed a bug where setting stop sequences on a model caused the chat to fail with a split error, preventing any responses from being returned. The fix handles both string and array formats for stop tokens. [#22251](https://github.com/open-webui/open-webui/issues/22251), [Commit](https://github.com/open-webui/open-webui/commit/c7d1d1e390a79c6c86d4bfe439fd7de6f5fb060f) +- 🔐 **Microsoft OAuth refresh token fix.** Fixed a bug where Microsoft OAuth refresh token requests failed with error AADSTS90009 by adding support for the required scope parameter. Users can now stay logged in reliably with Microsoft OAuth. [#22359](https://github.com/open-webui/open-webui/pull/22359) +- 🛠️ **Parameterless tool calls.** Fixed parameterless tool calls failing during streaming by correcting the default arguments initialization, eliminating unnecessary model retries. [#22189](https://github.com/open-webui/open-webui/pull/22189) +- 🔧 **Tool call streaming fixes.** Fixed two bugs where streaming tool calls failed silently for models like GPT-5: function names were incorrectly duplicated when sent in multiple delta chunks, and arguments containing multiple JSON objects were not properly split. Tools now execute correctly instead of failing without explanation. [#22177](https://github.com/open-webui/open-webui/issues/22177), [Commit](https://github.com/open-webui/open-webui/commit/d7efdcce2b1cdbe1637a469294bf9d52dbacab53), [Commit](https://github.com/open-webui/open-webui/commit/459a60a24240eab33441ed50f4f68cc27e65a037) +- 🔗 **Tool server URL trailing slash.** Fixed tool server connection failures when URLs have trailing slashes by stripping them before path concatenation. Previously, URLs like "http://host:8080/v1/" + "/openapi.json" produced double-slash URLs that some servers rejected. [#22116](https://github.com/open-webui/open-webui/pull/22116), [#21917](https://github.com/open-webui/open-webui/issues/21917) +- 🛡️ **Citation parser error handling.** Fixed crashes when tools return error strings instead of expected data structures by adding type guards to the citation parser. The system now returns an empty source list instead of crashing with AttributeError. [#22118](https://github.com/open-webui/open-webui/pull/22118) +- 🧠 **Artifacts memory leak.** Fixed a memory leak where Svelte store subscriptions in the Artifacts component were not properly cleaned up when the component unmounted, causing memory to accumulate over time. [#22303](https://github.com/open-webui/open-webui/pull/22303) +- ♾️ **Artifacts reactive loop fix.** Fixed an infinite reactive loop in chat when artifacts are present by moving the animation frame logic outside the reactive block, preventing continuous re-rendering and CPU usage. [#22238](https://github.com/open-webui/open-webui/pull/22238), [Commit](https://github.com/open-webui/open-webui/commit/626fcff417afba642f4f71e0498267a21435c524) +- 🔀 **Artifact navigation.** Artifact navigation via arrow buttons now works correctly; the selected artifact is no longer reset when content updates. [#22239](https://github.com/open-webui/open-webui/pull/22239) +- 🧩 **Artifact thinking block fix.** Fixed a bug where HTML preview rendered code blocks inside thinking blocks for certain models like Mistral and Z.ai, causing stray code with ">" symbols to appear before the actual artifact. The fix strips thinking blocks before extracting code for artifact rendering. [#22267](https://github.com/open-webui/open-webui/issues/22267), [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0) +- 💬 **Floating Quick Actions availability.** Fixed an issue where the "Ask" and "Explain" Floating Quick Actions were missing when selecting text in chats that used a model that is no longer available. [#22149](https://github.com/open-webui/open-webui/pull/22149), [#22139](https://github.com/open-webui/open-webui/issues/22139) +- 💡 **Follow-up suggestions.** Fixed follow-up suggestions not appearing by correcting contradictory format instructions in the prompt template, ensuring the LLM returns the correct JSON object format. [#22212](https://github.com/open-webui/open-webui/pull/22212) +- 🔊 **TTS thinking content.** Fixed TTS playback reading think tags instead of skipping them by handling edge cases where code blocks inside thinking content prevented proper tag removal. [#22237](https://github.com/open-webui/open-webui/pull/22237), [#22197](https://github.com/open-webui/open-webui/issues/22197) +- 🎨 **Button spinner alignment.** Button spinners across multiple modals now align correctly and stay on the same line as the button text, fixing layout issues when loading states are displayed. [#22227](https://github.com/open-webui/open-webui/pull/22227) +- 📶 **Terminal keepalive.** Terminal connections now stay active without being closed by idle timeouts from proxies or load balancers, and spurious disconnection messages no longer appear. [Commit](https://github.com/open-webui/open-webui/commit/ca2aaf0321c219d041e92e2c0c842a4e424732ef) +- 📥 **Chat archive handler.** The archive button in the chat navbar now actually archives the chat and refreshes the chat list, instead of doing nothing. [#22229](https://github.com/open-webui/open-webui/pull/22229) +- 🐍 **BeautifulSoup4 dependency.** Added the missing BeautifulSoup4 package to backend requirements, fixing failures when using features that depend on HTML parsing. [#22231](https://github.com/open-webui/open-webui/pull/22231) +- 👥 **Group users default sort.** Group members in the admin panel now sort by last active time by default instead of creation date, making it easier to find active users. [#22211](https://github.com/open-webui/open-webui/pull/22211) +- 🔓 **Tool access permissions.** Users can now change tool and skill access permissions from private to public without errors. [#22325](https://github.com/open-webui/open-webui/pull/22325), [#22324](https://github.com/open-webui/open-webui/issues/22324) +- 🖥️ **Open Terminal permission fix.** Open Terminal is now visible without requiring "Allow Speech to Text" permission, fixing an issue where users without microphone access couldn't access the terminal feature. [#22374](https://github.com/open-webui/open-webui/issues/22374), [Commit](https://github.com/open-webui/open-webui/commit/70a31a9a57bdd0690ac270f31ebd1b46e8fdfa98) +- 📌 **Stale pinned models cleanup.** Pinned models that are deleted or hidden are now automatically unpinned, keeping your pinned models list up to date. [Commit](https://github.com/open-webui/open-webui/commit/af4500e5040c8343d339cd88dd1d2fb6138c7a72) +- 📏 **OpenTelemetry metric descriptions.** Fixed conflicting metric instrument descriptions that caused warnings in the OpenTelemetry collector, resulting in cleaner telemetry logs for administrators. [#22293](https://github.com/open-webui/open-webui/pull/22293) +- 🔢 **Non-streaming token tracking.** Token usage from non-streaming chat responses is now correctly saved to the database, fixing missing token counts in the Admin Panel analytics. Previously, non-streaming responses saved NULL usage data, causing messages to be excluded from token aggregation queries. [#22166](https://github.com/open-webui/open-webui/pull/22166) +- ⌨️ **Inline code typing.** Fixed a bug where typing inline code with backticks incorrectly deleted the character immediately before the opening backtick, so text formatted as inline code now correctly produces the full word instead of missing the last character. [#20417](https://github.com/open-webui/open-webui/issues/20417), [Commit](https://github.com/open-webui/open-webui/commit/e303c3da3b174da9e92a79b174f85ba574ca06ef) +- 📝 **Variable input newlines.** Fixed a bug where variables containing newlines were not displayed correctly in chat messages, and input values from Windows systems are now properly normalized to use standard line endings. [#21447](https://github.com/open-webui/open-webui/issues/21447), [Commit](https://github.com/open-webui/open-webui/commit/7b2f597b30c77ef300d1966e1c6a3edfdb0c465d) +- 📷 **Android photo capture.** Fixed an issue where the first photo taken in chat appeared completely black on some Android devices by using an alternative canvas export method. [#22317](https://github.com/open-webui/open-webui/pull/22317) +- 🪟 **Open Terminal Windows path fix.** Fixed a bug where navigating back to parent directories on Windows added an incorrect leading slash, causing directory loads to fail. Paths are now properly normalized for Windows drive letters. [#22352](https://github.com/open-webui/open-webui/issues/22352), [Commit](https://github.com/open-webui/open-webui/commit/044fd1bd15cae06a5c56a321ca79d8362942f66a) +- 🖼️ **Chat overview profile image sizing.** Fixed a bug where profile images in the chat overview could shrink incorrectly in tight spaces. The images now maintain their proper size with the flex-shrink-0 property. [#22261](https://github.com/open-webui/open-webui/pull/22261) +- 📨 **Queued messages display.** Fixed an issue where queued messages could be cut off or hidden. The queued messages area now scrolls properly when content exceeds the visible area, showing up to 25% of the viewport height. [#22176](https://github.com/open-webui/open-webui/pull/22176) +- 🖌️ **Image generation in temporary chats.** Generated images now display correctly in temporary chat mode when using builtin image generation tools. Previously, images were not shown because the code was overwriting the image list with a null database response. [#22330](https://github.com/open-webui/open-webui/pull/22330), [#22309](https://github.com/open-webui/open-webui/issues/22309) +- 🤖 **Ollama model unload fix.** Fixed a bug where unloading a model from Ollama via the Open WebUI proxy failed with a "Field required" error for the prompt field. The proxy now correctly allows omitting the prompt when using keep_alive: 0 to unload models. [#22260](https://github.com/open-webui/open-webui/issues/22260), [Commit](https://github.com/open-webui/open-webui/commit/95b65ff751f91131b633cb128ff2decdd87c4a85) +- 🏷️ **Banner type dropdown fix.** Fixed a bug where selecting a banner type required two clicks to register, as the first selection was being swallowed due to DOM structure changes. The dropdown now works correctly on the first click. [#22378](https://github.com/open-webui/open-webui/pull/22378) +- 📈 **Analytics URL encoding fix.** Fixed a bug where the Analytics page failed to load data for models with slashes in their ID, such as "anthropic/claude-opus-4.6". The frontend now properly URL-encodes forward slashes, allowing model analytics to load correctly. [#22380](https://github.com/open-webui/open-webui/issues/22380), [#22382](https://github.com/open-webui/open-webui/pull/22382) +- 📋 **Analytics chat list duplicate fix.** Fixed a bug where the Analytics page chat list threw an "each_key_duplicate" Svelte error when chat IDs were duplicated during pagination. The fix adds deterministic ordering to prevent duplicate entries. [#22383](https://github.com/open-webui/open-webui/pull/22383) +- 📂 **Folder knowledge base native tool call fix.** Fixed a bug where folders with attached knowledge bases were querying the knowledge base twice when using native tool call mode. The fix now correctly separates knowledge files from regular attachments, letting the builtin query_knowledge_files tool handle knowledge searches instead of duplicating RAG queries. [#22236](https://github.com/open-webui/open-webui/issues/22236), [Commit](https://github.com/open-webui/open-webui/commit/967b1137dcb7a52615f17d086ee89095bb9b60f3), [Commit](https://github.com/open-webui/open-webui/commit/80b5896b70d07ea868e2010b187430d43c9808f0) + ## [0.8.8] - 2026-03-02 ### Added diff --git a/CHANGELOG_EXTRA.md b/CHANGELOG_EXTRA.md index 6d7bcb7cab..be0201145f 100644 --- a/CHANGELOG_EXTRA.md +++ b/CHANGELOG_EXTRA.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.9.1] - 2026.03.08 + +### Changed + +- 合并官方 0.8.9 改动 + ## [0.8.8.1] - 2026.03.03 ### Changed diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 4e6bf70eab..b0b51254e4 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -319,6 +319,13 @@ def __getattr__(self, key): 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", "oauth.merge_accounts_by_email", @@ -1921,7 +1928,7 @@ class BannerModel(BaseModel): - 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: @@ -2242,20 +2249,36 @@ class BannerModel(BaseModel): ] DEFAULT_CODE_INTERPRETER_PROMPT = """ -#### Tools Available - -1. **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 `` 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. - -Ensure that the tools are effectively utilized to achieve the highest-quality analysis for the user.""" +#### Code Interpreter + +You have access to a Python code interpreter via: `` + +- 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 `` 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.""" #################################### # Vector Database diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index a1124d982e..2dd195f0c5 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -790,6 +790,16 @@ def parse_section(section): 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" diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index 6050e37fa3..afc2e76621 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -102,11 +102,30 @@ def create_sqlcipher_connection(): 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") diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 1513a4b09d..d10e518f4a 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1457,46 +1457,52 @@ async def dispatch(self, request: Request, call_next): app.add_middleware(SecurityHeadersMiddleware) -class APIKeyRestrictionMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - auth_header = request.headers.get("Authorization") - token = None - - if auth_header: - parts = auth_header.split(" ", 1) - if len(parts) == 2: - token = parts[1] - - # 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() - ] - - request_path = request.url.path - - # Match exact path or prefix path - is_allowed = any( - request_path == allowed or request_path.startswith(allowed + "/") - for allowed in allowed_paths - ) - - if not is_allowed: - return JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "detail": "API key not allowed to access this endpoint." - }, +class APIKeyRestrictionMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + request = Request(scope) + auth_header = request.headers.get("Authorization") + token = None + + if auth_header: + parts = auth_header.split(" ", 1) + if len(parts) == 2: + token = parts[1] + + # 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() + ] + + request_path = request.url.path + + # 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) @@ -2269,6 +2275,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": { diff --git a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py index 567f7d673a..aa9b0a4c26 100644 --- a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py +++ b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py @@ -21,6 +21,39 @@ 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)" ) diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index e6e932be12..b707ab358b 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -292,9 +292,11 @@ def get_chat_ids_by_model_id( 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() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 8c6eb830b5..78aa4d3ded 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -734,13 +734,13 @@ def get_archived_chat_list_by_user_id( 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 @@ def get_shared_chat_list_by_user_id( 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 @@ def get_chat_list_by_user_id( 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 @@ def get_chat_title_id_list_by_user_id( 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 @@ def get_chats_by_user_id( 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 @@ def get_chats_by_user_id_and_search_text( 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 @@ def get_chats_by_folder_id_and_user_id( 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) diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 24a872bcc4..b491b831f2 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -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") diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index fdbfac5e7c..18916315e6 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -308,6 +308,28 @@ def get_function_valves_by_id( 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]: diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index 61aec66332..1bd12f7bdb 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -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)"), diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index ab0326c882..f6269e7b72 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -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: diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 1e69b36982..f497c5a35e 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -1195,7 +1195,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 diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index a0cb4fee45..e75b345ce8 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -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 diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index f2594ae5d0..e18b561321 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -19,7 +19,7 @@ ) 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 diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index e5b2daad51..938e3c3ee8 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -30,7 +30,7 @@ ) from open_webui.utils.tools import get_tool_specs from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_access, has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.utils.tools import get_tool_servers from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 758b530c92..3070959332 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -37,6 +37,7 @@ 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 @@ async def __event_caller__(event_data): "data": event_data, }, to=request_info["session_id"], + timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, ) return response diff --git a/backend/open_webui/static/apple-touch-icon.png b/backend/open_webui/static/apple-touch-icon.png deleted file mode 100644 index 9807373436..0000000000 Binary files a/backend/open_webui/static/apple-touch-icon.png and /dev/null differ diff --git a/backend/open_webui/static/custom.css b/backend/open_webui/static/custom.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/open_webui/static/favicon-96x96.png b/backend/open_webui/static/favicon-96x96.png deleted file mode 100644 index 2ebdffebe5..0000000000 Binary files a/backend/open_webui/static/favicon-96x96.png and /dev/null differ diff --git a/backend/open_webui/static/favicon-dark.png b/backend/open_webui/static/favicon-dark.png deleted file mode 100644 index 08627a23f7..0000000000 Binary files a/backend/open_webui/static/favicon-dark.png and /dev/null differ diff --git a/backend/open_webui/static/favicon.ico b/backend/open_webui/static/favicon.ico deleted file mode 100644 index 14c5f9c6d4..0000000000 Binary files a/backend/open_webui/static/favicon.ico and /dev/null differ diff --git a/backend/open_webui/static/favicon.png b/backend/open_webui/static/favicon.png deleted file mode 100644 index 63735ad461..0000000000 Binary files a/backend/open_webui/static/favicon.png and /dev/null differ diff --git a/backend/open_webui/static/favicon.svg b/backend/open_webui/static/favicon.svg deleted file mode 100644 index 0aa909745a..0000000000 --- a/backend/open_webui/static/favicon.svg +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/backend/open_webui/static/loader.js b/backend/open_webui/static/loader.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/open_webui/static/logo.png b/backend/open_webui/static/logo.png deleted file mode 100644 index a652a5fb87..0000000000 Binary files a/backend/open_webui/static/logo.png and /dev/null differ diff --git a/backend/open_webui/static/site.webmanifest b/backend/open_webui/static/site.webmanifest deleted file mode 100644 index 95915ae2bc..0000000000 --- a/backend/open_webui/static/site.webmanifest +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "Open WebUI", - "short_name": "WebUI", - "icons": [ - { - "src": "/static/web-app-manifest-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/static/web-app-manifest-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} \ No newline at end of file diff --git a/backend/open_webui/static/splash-dark.png b/backend/open_webui/static/splash-dark.png deleted file mode 100644 index 202c03f8e4..0000000000 Binary files a/backend/open_webui/static/splash-dark.png and /dev/null differ diff --git a/backend/open_webui/static/splash.png b/backend/open_webui/static/splash.png deleted file mode 100644 index 389196ca6a..0000000000 Binary files a/backend/open_webui/static/splash.png and /dev/null differ diff --git a/backend/open_webui/static/user-import.csv b/backend/open_webui/static/user-import.csv deleted file mode 100644 index 918a92aad7..0000000000 --- a/backend/open_webui/static/user-import.csv +++ /dev/null @@ -1 +0,0 @@ -Name,Email,Password,Role diff --git a/backend/open_webui/static/user.png b/backend/open_webui/static/user.png deleted file mode 100644 index 7bdc70d159..0000000000 Binary files a/backend/open_webui/static/user.png and /dev/null differ diff --git a/backend/open_webui/static/web-app-manifest-192x192.png b/backend/open_webui/static/web-app-manifest-192x192.png deleted file mode 100644 index fbd2eab6e2..0000000000 Binary files a/backend/open_webui/static/web-app-manifest-192x192.png and /dev/null differ diff --git a/backend/open_webui/static/web-app-manifest-512x512.png b/backend/open_webui/static/web-app-manifest-512x512.png deleted file mode 100644 index afebe2cd08..0000000000 Binary files a/backend/open_webui/static/web-app-manifest-512x512.png and /dev/null differ diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index eec760fe01..b438759e10 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -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 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): "session_id": ( __metadata__.get("session_id") if __metadata__ else None ), + "files": ( + __metadata__.get("files", []) if __metadata__ else [] + ), }, } ) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 9c11c96447..8bfed7d391 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1,3 +1,4 @@ +import copy import time import logging import sys @@ -119,6 +120,7 @@ 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 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'{body}\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 = {} - - 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'{body}\n" - ) + context = get_source_context(sources, include_content=include_content) - 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 tool_function(**kwargs): 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 flush_pending_delta_data(threshold: int = 0): 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 flush_pending_delta_data(threshold: int = 0): 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 flush_pending_delta_data(threshold: int = 0): 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 flush_pending_delta_data(threshold: int = 0): 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 flush_pending_delta_data(threshold: int = 0): 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 flush_pending_delta_data(threshold: int = 0): } ) - # 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). + # Restore pre-RAG message state before re-applying + # to prevent RAG template duplication. + original_user_message = ( + metadata.get("user_prompt") or user_message + ) set_last_user_message_content( - user_message, form_data["messages"] + original_user_message, + form_data["messages"], ) - 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, + 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 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): "session_id": metadata.get( "session_id", None ), + "files": metadata.get("files", []), }, } ) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 6c82b4aa98..108cd0b4b0 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -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() - - # Set action_ids and filter_ids - action_ids = [] - filter_ids = [] - - 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", []) - ) - - if "params" in model["info"]: - # Remove params to avoid exposing sensitive info - del model["info"]["params"] - - model["action_ids"] = action_ids - model["filter_ids"] = filter_ids - else: - models.remove(model) - - elif custom_model.is_active and ( - custom_model.id not in [model["id"] for model in models] - ): - # Custom model based on a base model + # Override applied directly to a base model (shares the same ID) + model = base_model_lookup.get(custom_model.id) + + if model: + if custom_model.is_active: + model["name"] = custom_model.name + model["info"] = custom_model.model_dump() + + action_ids = [] + filter_ids = [] + + 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", []) + ) + + 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 + 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 @@ def get_filter_items_from_module(function, module): 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: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 284917d22c..392ae74f96 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -36,6 +36,7 @@ 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 @@ class OAuthClientInformationFull(OAuthClientMetadata): 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 @@ -787,6 +791,16 @@ async def _perform_token_refresh(self, session) -> dict: 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 @@ async def _perform_token_refresh(self, session) -> dict: 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( diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py index 17536b9adb..25cd027d0e 100644 --- a/backend/open_webui/utils/telemetry/instrumentors.py +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -20,6 +20,7 @@ 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 @@ def _instrument(self, **kwargs): request_hook=aiohttp_request_hook, response_hook=aiohttp_response_hook, ) + SystemMetricsInstrumentor().instrument() def _uninstrument(self, **kwargs): if getattr(self, "instrumentors", None) is None: diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index f129f5f002..cafe779d80 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -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", ) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 2c4b983bd8..081e2f5fab 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -432,6 +432,10 @@ def is_builtin_tool_enabled(category: str) -> bool: # 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 @@ -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}" diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 532a6cd714..13bd199f08 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index ea4966069e..5c77098dd0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 @@ -84,12 +85,12 @@ sentencepiece jsonpath-ng 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 @@ -97,10 +98,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 @@ -118,10 +119,10 @@ psycopg2-binary==2.9.11 pgvector==0.4.2 PyMySQL==1.1.2 -boto3==1.42.44 +boto3==1.42.62 -pymilvus==2.6.8 -qdrant-client==1.16.2 +pymilvus==2.6.9 +qdrant-client==1.17.0 playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary elasticsearch==9.3.0 pinecone==6.0.2 @@ -141,20 +142,20 @@ 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 # Alipay alipay-sdk-python==3.7.796 diff --git a/package-lock.json b/package-lock.json index 10d097843b..cabfd5b46e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.8.1", + "version": "0.8.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.8.1", + "version": "0.8.9.1", "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", @@ -68,6 +69,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", @@ -91,8 +93,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", @@ -2680,6 +2684,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", @@ -3268,9 +3372,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": { @@ -3278,8 +3382,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" } @@ -3527,9 +3631,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": { @@ -3537,8 +3641,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": { @@ -3608,9 +3712,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": { @@ -3618,7 +3722,7 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-typography": { @@ -4061,6 +4165,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", @@ -4124,9 +4237,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", @@ -5193,6 +5307,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", @@ -5241,6 +5365,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", @@ -5763,6 +5907,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", @@ -8092,6 +8246,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", @@ -8168,6 +8358,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", @@ -9613,6 +9813,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", @@ -9685,6 +9906,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", @@ -10000,6 +10310,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", @@ -10645,6 +10972,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", @@ -11097,6 +11434,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", @@ -11867,6 +12228,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", @@ -11993,12 +12373,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", @@ -12153,6 +12549,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", @@ -12671,6 +13081,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", @@ -12827,6 +13247,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", @@ -13336,6 +13824,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", @@ -14632,6 +15148,16 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" + }, + "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" + } } } } diff --git a/package.json b/package.json index 5134692696..947db0c9de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.8.1", + "version": "0.8.9.1", "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", @@ -112,6 +113,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", @@ -135,8 +137,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", diff --git a/pyproject.toml b/pyproject.toml index d240449dff..251240ac48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,8 @@ authors = [ ] license = { file = "LICENSE" } dependencies = [ - "fastapi==0.128.5", - "uvicorn[standard]==0.40.0", + "fastapi==0.135.1", + "uvicorn[standard]==0.41.0", "pydantic==2.12.5", "python-multipart==0.0.22", "itsdangerous==2.2.0", @@ -18,7 +18,7 @@ dependencies = [ "bcrypt==5.0.0", "argon2-cffi==25.1.0", "PyJWT[crypto]==2.11.0", - "authlib==1.6.7", + "authlib==1.6.9", "requests==2.32.5", "aiohttp==3.13.2", # do not update to 3.13.3 - broken @@ -31,15 +31,15 @@ dependencies = [ "starsessions[redis]==2.2.1", "python-mimeparse==2.0.0", - "sqlalchemy==2.0.46", - "alembic==1.18.3", + "sqlalchemy==2.0.48", + "alembic==1.18.4", "peewee==3.19.0", "peewee-migrate==1.14.3", - "pycrdt==0.12.46", + "pycrdt==0.12.47", "redis", - "pytz==2025.2", + "pytz==2026.1.post1", "APScheduler==3.11.2", "RestrictedPython==8.1", @@ -51,38 +51,38 @@ dependencies = [ "openai", "anthropic", - "google-genai==1.62.0", + "google-genai==1.66.0", - "langchain==1.2.9", + "langchain==1.2.10", "langchain-community==0.4.1", "langchain-classic==1.0.1", - "langchain-text-splitters==1.1.0", + "langchain-text-splitters==1.1.1", "fake-useragent==2.2.0", - "chromadb==1.4.1", + "chromadb==1.5.2", "opensearch-py==3.1.0", "PyMySQL==1.1.2", - "boto3==1.42.44", + "boto3==1.42.62", - "transformers==5.1.0", - "sentence-transformers==5.2.2", + "transformers==5.3.0", + "sentence-transformers==5.2.3", "accelerate", "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897 "einops==0.8.2", "ftfy==6.3.1", "chardet==5.2.0", - "pypdf==6.7.0", - "fpdf2==2.8.5", - "pymdown-extensions==10.20.1", + "pypdf==6.7.5", + "fpdf2==2.8.7", + "pymdown-extensions==10.21", "docx2txt==0.9", "python-pptx==1.0.2", "unstructured==0.18.31", "msoffcrypto-tool==6.0.0", - "nltk==3.9.2", - "Markdown==3.10.1", + "nltk==3.9.3", + "Markdown==3.10.2", "pypandoc==1.16.2", - "pandas==3.0.0", + "pandas==3.0.1", "openpyxl==3.1.5", "pyxlsb==1.0.10", "xlrd==2.0.2", @@ -92,12 +92,12 @@ dependencies = [ "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", - "pillow==12.1.0", + "pillow==12.1.1", "opencv-python-headless==4.13.0.92", "rapidocr-onnxruntime==1.4.4", "rank-bm25==0.2.2", - "onnxruntime==1.24.1", + "onnxruntime==1.24.3", "faster-whisper==1.2.1", "black==26.1.0", @@ -105,7 +105,7 @@ dependencies = [ "pytube==15.0.0", "pydub", - "ddgs==9.10.0", + "ddgs==9.11.2", "google-api-python-client", "google-auth-httplib2", @@ -114,7 +114,7 @@ dependencies = [ "googleapis-common-protos==1.72.0", "google-cloud-storage==3.9.0", - "azure-identity==1.25.1", + "azure-identity==1.25.2", "azure-storage-blob==12.28.0", "ldap3==2.9.1", @@ -150,15 +150,15 @@ all = [ "playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary "elasticsearch==9.3.0", - "qdrant-client==1.16.2", + "qdrant-client==1.17.0", - "weaviate-client==4.19.2", - "pymilvus==2.6.8", + "weaviate-client==4.20.3", + "pymilvus==2.6.9", "pinecone==6.0.2", "oracledb==3.4.2", "colbert-ai==0.2.22", - "firecrawl-py==4.14.0", + "firecrawl-py==4.18.0", "azure-search-documents==11.6.0", ] diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index 535adbd5f6..af5405de3c 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -4,6 +4,7 @@ type FolderForm = { name?: string; data?: Record; meta?: Record; + parent_id?: string | null; }; export const createNewFolder = async (token: string, folderForm: FolderForm) => { diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index aa99810753..748ada33a0 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -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 => { + 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 = { 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 => { + const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}`; + const res = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + return res?.ok ?? false; +}; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 8a8e269aea..76d67216f0 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -699,7 +699,7 @@ {/if} diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index db61a58e77..2f5e30a7c5 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -898,7 +898,7 @@ {/if} diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte index fff9c12acf..2163ce26c8 100644 --- a/src/lib/components/ImportModal.svelte +++ b/src/lib/components/ImportModal.svelte @@ -104,7 +104,7 @@
+ {/if} diff --git a/src/lib/components/admin/Settings/CodeExecution.svelte b/src/lib/components/admin/Settings/CodeExecution.svelte index 23388c0a45..6195672869 100644 --- a/src/lib/components/admin/Settings/CodeExecution.svelte +++ b/src/lib/components/admin/Settings/CodeExecution.svelte @@ -67,7 +67,8 @@ > {#each engines as engine} - + {/each} @@ -193,7 +194,9 @@ > {#each engines as engine} - + {/each} diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 78e300ae04..ece64afd54 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -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 !== ''), diff --git a/src/lib/components/admin/Settings/Images.svelte b/src/lib/components/admin/Settings/Images.svelte index bf3ce6da19..5fe84d700a 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -1270,7 +1270,7 @@
+ {/if} diff --git a/src/lib/components/admin/Settings/Interface/Banners.svelte b/src/lib/components/admin/Settings/Interface/Banners.svelte index 6b96374d5e..5ebf9c7685 100644 --- a/src/lib/components/admin/Settings/Interface/Banners.svelte +++ b/src/lib/components/admin/Settings/Interface/Banners.svelte @@ -64,9 +64,7 @@ bind:value={banner.type} required > - {#if banner.type == ''} - - {/if} + diff --git a/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte b/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte index 1850940503..14b6e6d34c 100644 --- a/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte +++ b/src/lib/components/admin/Settings/Models/ModelSettingsModal.svelte @@ -436,7 +436,7 @@ diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index c93fc37ebd..b3290a11e4 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -219,7 +219,7 @@ {#if ['general', 'permissions'].includes(selectedTab)}
+ {/if} diff --git a/src/lib/components/admin/Users/Groups/Users.svelte b/src/lib/components/admin/Users/Groups/Users.svelte index ac1daade87..ace5141a7f 100644 --- a/src/lib/components/admin/Users/Groups/Users.svelte +++ b/src/lib/components/admin/Users/Groups/Users.svelte @@ -31,7 +31,7 @@ let query = ''; let searchDebounceTimer: ReturnType; - 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; diff --git a/src/lib/components/admin/Users/UserList/AddUserModal.svelte b/src/lib/components/admin/Users/UserList/AddUserModal.svelte index 191a8b134e..84cfd4ffb6 100644 --- a/src/lib/components/admin/Users/UserList/AddUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/AddUserModal.svelte @@ -285,7 +285,7 @@
+ {/if} diff --git a/src/lib/components/channel/WebhooksModal.svelte b/src/lib/components/channel/WebhooksModal.svelte index c095e38018..820a7f3cb5 100644 --- a/src/lib/components/channel/WebhooksModal.svelte +++ b/src/lib/components/channel/WebhooksModal.svelte @@ -160,7 +160,7 @@
diff --git a/src/lib/components/chat/Artifacts.svelte b/src/lib/components/chat/Artifacts.svelte index bf0681dc19..7e0bdf2060 100644 --- a/src/lib/components/chat/Artifacts.svelte +++ b/src/lib/components/chat/Artifacts.svelte @@ -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(); + }; }); diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 4bd565e89f..ed1a2dcbe3 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -65,6 +65,7 @@ import { AudioQueue } from '$lib/utils/audio'; import { + archiveChatById, createNewChat, getAllTags, getChatById, @@ -105,6 +106,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 = ''; @@ -406,11 +408,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('/'); } }; @@ -645,6 +650,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(); @@ -923,15 +935,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, @@ -2593,6 +2621,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.')); + } + }; @@ -2675,7 +2722,7 @@ bind:selectedModels shareEnabled={!!history.currentId} {initNewChat} - archiveChatHandler={() => {}} + {archiveChatHandler} {moveChatHandler} onSaveTempChat={async () => { try { @@ -2885,6 +2932,7 @@ {stopResponse} {showMessage} {eventTarget} + {codeInterpreterEnabled} /> diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index 2f5aec4b09..aea9fa83cf 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -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'} {:else if activeTab === 'files' && $selectedTerminalId} + {:else if activeTab === 'files' && codeInterpreterEnabled} + {:else} {/if} @@ -401,7 +409,10 @@
{#if $showCallOverlay} @@ -483,7 +494,7 @@ ? 'h-full' : activeTab === 'controls' ? 'overflow-y-auto px-3 pt-1' - : 'overflow-y-auto'}" + : ''}" > {#if activeTab === 'overview'} {:else if activeTab === 'files' && $selectedTerminalId} + {:else if activeTab === 'files' && codeInterpreterEnabled} + {:else} {/if} diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index b6ba861e6a..81ee750a22 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -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 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: '/' }] - ); + const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? ''); + + /** 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); }); @@ -496,7 +598,7 @@ {:else}
(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)} + + {:else if isHtml} + + {:else if isCode} + + + + {:else if editing} + + {/if} - + + + + + + {#if !selectedFile} + {/each} +
+ {/if} +
+ {:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0} +
+
+ Slide {currentSlide + 1} +
+ {#if fileOfficeSlides.length > 1} +
+ + {currentSlide + 1} / {fileOfficeSlides.length} + +
+ {/if} +
{: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} +
+ +
{:else if isMarkdown && !showRaw} -
+
{@html renderedHtml}
{:else if isCsv && !showRaw && csvRows.length > 0} -
- +
+
@@ -214,6 +451,40 @@
#
+ {:else if isNotebook && !showRaw && parsedNotebook} +
+ +
+ {:else if isJson && !showRaw && parsedJson !== undefined} +
+ +
+ {:else if isJson && !showRaw && jsonError} +
+
JSON parse error: {jsonError}
+
{fileContent}
+
+ {:else if isSvg && !showRaw && fileContent} +
+ {@html DOMPurify.sanitize(fileContent, { + USE_PROFILES: { svg: true, svgFilters: true }, + ADD_TAGS: ['use'] + })} +
+ {:else if isCode && !showRaw} +
+ +
+ {:else if isSvg && highlightedHtml && !showRaw} +
+ {@html highlightedHtml} +
{:else if editing} + {:else} + +
startEditing(i)} + > + {@html renderMarkdown(toStr(cell.source))} +
+ {/if} + {:else if cell.cell_type === 'code'} +
+
+ {#if runningCell === i} +
+ {:else if baseUrl && apiKey && filePath} + + {/if} +
+ {#if cell.execution_count !== undefined && cell.execution_count !== null} + [{cell.execution_count}] + {:else} + [ ] + {/if} +
+
+
+ {#if editingCell[i]} + { + editedSources[i] = e.detail; + }} + on:run={() => runCell(i)} + on:cancel={() => cancelEditing(i)} + /> + {:else} + +
startEditing(i)} + > + {#if highlightedCells[i]} +
+ {@html highlightedCells[i]} +
+ {:else} +
{toStr(cell.source)}
+ {/if} +
+ {/if} + + {#if cell.outputs && cell.outputs.length > 0} +
+ {#each cell.outputs as output} + {#if output.output_type === 'error'} +
{stripAnsi(
+												(output.traceback ?? []).join('\n') || `${output.ename}: ${output.evalue}`
+											)}
+ {:else} + {@const html = getOutputHtml(output)} + {@const images = getOutputImages(output)} + {@const text = getOutputText(output)} + {#if html} +
{@html html}
+ {/if} + {#each images as src} + Output + {/each} + {#if text} +
{text}
+ {/if} + {/if} + {/each} +
+ {/if} +
+
+ {:else if cell.cell_type === 'raw'} +
{toStr(cell.source)}
+ {/if} +
+ {/each} + + + diff --git a/src/lib/components/chat/FileNav/PortList.svelte b/src/lib/components/chat/FileNav/PortList.svelte new file mode 100644 index 0000000000..8a5dfb3cd2 --- /dev/null +++ b/src/lib/components/chat/FileNav/PortList.svelte @@ -0,0 +1,139 @@ + + +
+ + + + + + {#if expanded} +
+ {#if ports.length === 0} +
+ {$i18n.t('No servers detected')} +
+ {:else} + {#each ports as port} + + {/each} + {/if} +
+ {/if} +
diff --git a/src/lib/components/chat/FileNav/SqliteView.svelte b/src/lib/components/chat/FileNav/SqliteView.svelte new file mode 100644 index 0000000000..74072c5a3e --- /dev/null +++ b/src/lib/components/chat/FileNav/SqliteView.svelte @@ -0,0 +1,373 @@ + + +
+ {#if loading} +
+ {:else if error} +
{error}
+ {:else} + +
+ {#each tables as table} + + {/each} +
+ +
+ + {#if queryMode} + +
+ +
+ {#if queryError} + {queryError} + {:else} + ⌘+Enter + {/if} + +
+
+ + {#if queryColumns.length > 0} +
+ + + + + {#each queryColumns as col} + + {/each} + + + + {#each queryRows as row, i} + + + {#each row as cell} + + {/each} + + {/each} + +
#{col}
{i + 1}{cell}
+
+ {/if} + {:else} + +
+ {#if columns.length > 0} + + + + + {#each columns as col} + + {/each} + + + + {#each rows as row, i} + + + {#each row as cell} + + {/each} + + {/each} + +
#{col}
{page * pageSize + i + 1}{cell}
+ {:else} +
{$i18n.t('No data')}
+ {/if} +
+ + + {#if totalPages > 1} +
+ + {page + 1} / {totalPages} ({totalRows.toLocaleString()} rows) + +
+ {/if} + {/if} + {/if} +
+ + diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index f3108abf25..9bf3a08139 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -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,7 +1181,7 @@ {#if messageQueue.length > 0}
{#each messageQueue as queuedMessage (queuedMessage.id)} {/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} {#if ($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url)} {/if} - - - - + + + + + + + {/if} {/if} {#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))} diff --git a/src/lib/components/chat/MessageInput/InputVariablesModal.svelte b/src/lib/components/chat/MessageInput/InputVariablesModal.svelte index d2410ad80b..b50ed38395 100644 --- a/src/lib/components/chat/MessageInput/InputVariablesModal.svelte +++ b/src/lib/components/chat/MessageInput/InputVariablesModal.svelte @@ -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; }; diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index dbd8daa16e..e8ada5c57c 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -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. diff --git a/src/lib/components/chat/Messages/ContentRenderer.svelte b/src/lib/components/chat/Messages/ContentRenderer.svelte index cb0f6e7aeb..663fafd71c 100644 --- a/src/lib/components/chat/Messages/ContentRenderer.svelte +++ b/src/lib/components/chat/Messages/ContentRenderer.svelte @@ -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 @@ />
-{#if floatingButtons && model} +{#if floatingButtons} 0 ? selectedModels.at(0) - : model?.id} + : (model?.id ?? null)} messages={createMessagesList(history, messageId)} onAdd={({ modelId, parentId, messages }) => { console.log(modelId, parentId, messages); diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 6b66c7e084..ed1a0c505c 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -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(); + }); + } } - } + }; + + $: updateHandler(content); - onDestroy(() => { + // Throttle parsing to once per animation frame while streaming + $: onDestroy(() => { cancelAnimationFrame(pendingUpdate); }); diff --git a/src/lib/components/chat/Overview/Node.svelte b/src/lib/components/chat/Overview/Node.svelte index 0bf36bb918..17c5e51e0c 100644 --- a/src/lib/components/chat/Overview/Node.svelte +++ b/src/lib/components/chat/Overview/Node.svelte @@ -25,7 +25,7 @@
@@ -45,7 +45,7 @@
diff --git a/src/lib/components/chat/PyodideFileNav.svelte b/src/lib/components/chat/PyodideFileNav.svelte new file mode 100644 index 0000000000..73a10325d0 --- /dev/null +++ b/src/lib/components/chat/PyodideFileNav.svelte @@ -0,0 +1,471 @@ + + + + + + +
(isDragOver = false)} + on:drop={handleDrop} + role="region" + aria-label={$i18n.t('Pyodide file browser')} +> + {#if isDragOver} +
+ + + + {$i18n.t('Drop files here')} +
+ {/if} + + {#if overlay} +
+ {/if} + + + 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={() => {}} + > + + + + + + +
+ {#if selectedFile} + + {:else if loading} +
+ +
+ {:else if error} +
+
{error}
+
+ {:else if entries.length === 0 && !creatingFolder && !creatingFile} +
+ +
+ {$i18n.t('No files yet. Upload files or run Python code to create them.')} +
+
+ {/if} + + {#if !loading && !error && !selectedFile} + {#if creatingFolder} +
+ + { + if (e.key === 'Enter') submitNewFolder(); + if (e.key === 'Escape') { + creatingFolder = false; + newFolderName = ''; + } + }} + on:blur={submitNewFolder} + /> +
+ {/if} + {#if creatingFile} +
+ + { + if (e.key === 'Enter') submitNewFile(); + if (e.key === 'Escape') { + creatingFile = false; + newFileName = ''; + } + }} + on:blur={submitNewFile} + /> +
+ {/if} + + {#if entries.length > 0 || creatingFolder || creatingFile} +
    + {#each entries as entry (entry.name)} + + {/each} +
+ {/if} + {/if} +
+
diff --git a/src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte b/src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte index 9f52fd8c8d..570e41880a 100644 --- a/src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte +++ b/src/lib/components/chat/Settings/Personalization/AddMemoryModal.svelte @@ -76,7 +76,7 @@
+ {/if}
diff --git a/src/lib/components/chat/Settings/Personalization/EditMemoryModal.svelte b/src/lib/components/chat/Settings/Personalization/EditMemoryModal.svelte index 9baa9ab27c..0f16200c1e 100644 --- a/src/lib/components/chat/Settings/Personalization/EditMemoryModal.svelte +++ b/src/lib/components/chat/Settings/Personalization/EditMemoryModal.svelte @@ -86,7 +86,7 @@
+ {/if}
diff --git a/src/lib/components/chat/XTerminal.svelte b/src/lib/components/chat/XTerminal.svelte index eae497a500..e10ba02596 100644 --- a/src/lib/components/chat/XTerminal.svelte +++ b/src/lib/components/chat/XTerminal.svelte @@ -20,6 +20,7 @@ export let connected = false; export let connecting = false; let resizeObserver: ResizeObserver | null = null; + let pingInterval: ReturnType | 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 diff --git a/src/lib/components/common/FileItemModal.svelte b/src/lib/components/common/FileItemModal.svelte index 9d617f3604..ea161a5c28 100644 --- a/src/lib/components/common/FileItemModal.svelte +++ b/src/lib/components/common/FileItemModal.svelte @@ -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 @@
{/if} - {#if isAudio || isPDF || isExcel || isCode || isMarkdown} + {#if isAudio || isPDF || isExcel || isCode || isMarkdown || isDocx || isPptx}
@@ -510,7 +559,7 @@ {/if} {#if excelHtml} -
+
{@html excelHtml}
{:else} @@ -534,6 +583,77 @@ >
+ {:else if isDocx} + {#if docxError} +
{docxError}
+ {:else if docxHtml} +
+ {@html docxHtml} +
+ {:else} +
No content available
+ {/if} + {:else if isPptx} + {#if pptxError} +
{pptxError}
+ {:else if pptxSlides.length > 0} +
+
+ Slide {pptxCurrentSlide + 1} +
+ {#if pptxSlides.length > 1} +
+ + {pptxCurrentSlide + 1} / {pptxSlides.length} + +
+ {/if} +
+ {:else} +
No content available
+ {/if} {:else}
{(item?.file?.data?.content ?? '').trim() || 'No content'} diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index aea8ca7191..b9dd058553 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -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, diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 27f9af9055..253ba18ded 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -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; diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index 8ad07ad230..77931c779d 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -79,6 +79,9 @@ let mouseOver = false; let draggable = false; + $: if (mouseOver) { + loadChat(); + } const loadChat = async () => { if (!chat) { @@ -153,8 +156,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) => { diff --git a/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte b/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte index c9cac29c77..7d0c2faf8c 100644 --- a/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte +++ b/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte @@ -11,11 +11,13 @@ import Pencil from '$lib/components/icons/Pencil.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Download from '$lib/components/icons/Download.svelte'; + import Folder from '$lib/components/icons/Folder.svelte'; export let align: 'start' | 'end' = 'start'; export let onEdit = () => {}; export let onExport = () => {}; export let onDelete = () => {}; + export let onCreateSub = () => {}; let show = false; @@ -47,6 +49,18 @@ {align} transition={flyAndScale} > + { + onCreateSub(); + }} + > + +
{$i18n.t('Create Folder')}
+
+ +
+ { diff --git a/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte b/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte index f1d0975282..73248a59c2 100644 --- a/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte +++ b/src/lib/components/layout/Sidebar/Folders/FolderModal.svelte @@ -19,6 +19,7 @@ export let onSubmit: Function = (e) => {}; export let folderId = null; + export let parentId = null; export let edit = false; let folder = null; @@ -55,7 +56,8 @@ await onSubmit({ name, meta, - data + data, + parent_id: edit ? undefined : parentId }); show = false; loading = false; diff --git a/src/lib/components/layout/Sidebar/PinnedModelList.svelte b/src/lib/components/layout/Sidebar/PinnedModelList.svelte index da35a3df3d..235dda9034 100644 --- a/src/lib/components/layout/Sidebar/PinnedModelList.svelte +++ b/src/lib/components/layout/Sidebar/PinnedModelList.svelte @@ -37,6 +37,20 @@ let unsubscribeSettings; + const cleanupStalePinnedModels = async (modelIds) => { + const validModels = modelIds.filter((id) => { + const model = $models.find((m) => m.id === id); + // Remove if model not found (deleted) or if hidden + return model && !(model?.info?.meta?.hidden ?? false); + }); + + if (validModels.length !== modelIds.length) { + pinnedModels = validModels; + settings.set({ ...$settings, pinnedModels: validModels }); + await updateUserSettings(localStorage.token, { ui: $settings }); + } + }; + onMount(async () => { pinnedModels = $settings?.pinnedModels ?? []; @@ -48,6 +62,11 @@ await updateUserSettings(localStorage.token, { ui: $settings }); } + // Auto-unpin hidden or deleted models + if (pinnedModels.length > 0) { + await cleanupStalePinnedModels(pinnedModels); + } + unsubscribeSettings = settings.subscribe((value) => { pinnedModels = value?.pinnedModels ?? []; }); diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte index f800cfe9ba..3557a4fd5d 100644 --- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte +++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte @@ -18,7 +18,8 @@ updateFolderIsExpandedById, updateFolderById, updateFolderParentIdById, - getFolderById + getFolderById, + createNewFolder } from '$lib/apis/folders'; import { getChatById, @@ -64,6 +65,9 @@ let showFolderModal = false; let edit = false; + let showCreateSubFolderModal = false; + let createSubFolderParentId = null; + let draggedOver = false; let dragged = false; @@ -414,6 +418,30 @@ saveAs(blob, `folder-${folders[folderId].name}-export-${Date.now()}.json`); }; + + const createSubFolderHandler = async ({ name, meta, data, parent_id }) => { + if (name === '') { + toast.error($i18n.t('Folder name cannot be empty.')); + return; + } + + name = name.trim(); + + const res = await createNewFolder(localStorage.token, { + name, + data, + meta, + parent_id + }).catch((error) => { + toast.error(`${error}`); + return null; + }); + + if (res) { + toast.success($i18n.t('Folder created successfully')); + dispatch('update'); + } + }; + + {#if dragged && x && y}
@@ -593,6 +627,10 @@ onExport={() => { exportHandler(); }} + onCreateSub={() => { + createSubFolderParentId = folderId; + showCreateSubFolderModal = true; + }} >
diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 1966311bef..6f2c573acf 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -230,7 +230,11 @@ } info.params.system = system.trim() === '' ? null : system; - info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null; + info.params.stop = params.stop + ? (typeof params.stop === 'string' ? params.stop.split(',') : params.stop).filter((s) => + s.trim() + ) + : null; Object.keys(info.params).forEach((key) => { if (info.params[key] === '' || info.params[key] === null) { delete info.params[key]; diff --git a/src/lib/components/workspace/Skills/SkillEditor.svelte b/src/lib/components/workspace/Skills/SkillEditor.svelte index 62927b142b..7537ec45e7 100644 --- a/src/lib/components/workspace/Skills/SkillEditor.svelte +++ b/src/lib/components/workspace/Skills/SkillEditor.svelte @@ -252,15 +252,15 @@
{#if !disabled} {/if} diff --git a/src/lib/components/workspace/common/ValvesModal.svelte b/src/lib/components/workspace/common/ValvesModal.svelte index 577ce2eb04..16f3af4b6b 100644 --- a/src/lib/components/workspace/common/ValvesModal.svelte +++ b/src/lib/components/workspace/common/ValvesModal.svelte @@ -184,7 +184,7 @@
+ {/if}
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 2f81884b91..96ce2b4717 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -192,6 +192,7 @@ "Are you sure you want to delete this channel?": "", "Are you sure you want to delete this message?": "", "Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "", + "Are you sure you want to delete this?": "", "Are you sure you want to unarchive all archived chats?": "", "Arena Models": "", "Artifacts": "", @@ -296,6 +297,7 @@ "Charge Amount Control": "", "Chart new frontiers": "", "Chat": "", + "Chat archived.": "", "Chat Background Image": "", "Chat Bubble UI": "", "Chat Completions": "", @@ -521,6 +523,7 @@ "Delete": "", "Delete {{count}} Logs Successfully_one": "", "Delete {{count}} Logs Successfully_other": "", + "Delete {{name}}": "", "Delete a model": "", "Delete All": "", "Delete All Chats": "", @@ -615,6 +618,7 @@ "Downloading stats...": "", "Draw": "", "Drop any files here to upload": "", + "Drop files here": "", "Drop files here to upload": "", "DuckDuckGo": "", "e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "", @@ -878,6 +882,7 @@ "Fade Effect for Streaming Text": "", "Failed to add file.": "", "Failed to add members": "", + "Failed to archive chat.": "", "Failed to attach file": "", "Failed to clear status": "", "Failed to connect to {{URL}} OpenAPI tool server": "", @@ -892,9 +897,11 @@ "Failed to generate title": "", "Failed to import models": "", "Failed to load chat preview": "", + "Failed to load DOCX file. Please try downloading it instead.": "", "Failed to load Excel/CSV file. Please try downloading it instead.": "", "Failed to load file content.": "", "Failed to load Interface settings": "", + "Failed to load PPTX file. Please try downloading it instead.": "", "Failed to move chat": "", "Failed to process URL: {{url}}": "", "Failed to read clipboard contents": "", @@ -950,6 +957,7 @@ "Focus Chat Input": "", "Folder": "", "Folder Background Image": "", + "Folder created successfully": "", "Folder deleted successfully": "", "Folder Max File Count": "", "Folder name": "", @@ -1375,11 +1383,13 @@ "No file selected": "", "No files found": "", "No files in this knowledge base.": "", + "No files yet. Upload files or run Python code to create them.": "", "No functions found": "", "No groups found": "", "No history available": "", "No HTML, CSS, or JavaScript content found.": "", "No inference engine with management support found": "", + "No kernel": "", "No knowledge bases found.": "", "No knowledge found": "", "No Log": "", @@ -1397,6 +1407,7 @@ "No results": "", "No results found": "", "No search query generated": "", + "No servers detected": "", "No skills found": "", "No source available": "", "No sources found": "", @@ -1563,6 +1574,7 @@ "Please use a private key in PKCS#1 format. You can convert it using a format-conversion tool.": "", "Please wait until all files are uploaded.": "", "Port": "", + "Ports": "", "Positive attitude": "", "Prefer not to say": "", "Prefix ID": "", @@ -1600,6 +1612,7 @@ "Pull \"{{searchValue}}\" from Ollama.com": "", "Pull a model from Ollama.com": "", "Pull Model": "", + "Pyodide file browser": "", "QRCode": "", "Query Generation Prompt": "", "Querying": "", @@ -1681,6 +1694,7 @@ "Response splitting": "", "Response Watermark": "", "Responses": "", + "Restart": "", "Result": "", "RESULT": "", "Retrieval": "", @@ -1693,6 +1707,7 @@ "Role": "", "RTL": "", "Run": "", + "Run All": "", "Running": "", "Running...": "", "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "", @@ -1891,6 +1906,7 @@ "Start a new conversation": "", "Start of the channel": "", "Start Tag": "", + "Starting kernel...": "", "Status": "", "Status cleared successfully": "", "Status updated successfully": "", @@ -2260,6 +2276,8 @@ "You're now logged in.": "", "Your Account": "", "Your account status is currently pending activation.": "", + "Your browser does not support the audio tag.": "", + "Your browser does not support the video tag.": "", "Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "", "Your message text or inputs": "", "Your usage stats have been successfully synced.": "", diff --git a/src/lib/i18n/locales/zh-CN/translation.json b/src/lib/i18n/locales/zh-CN/translation.json index 90c0fc8baa..901097411c 100644 --- a/src/lib/i18n/locales/zh-CN/translation.json +++ b/src/lib/i18n/locales/zh-CN/translation.json @@ -192,6 +192,7 @@ "Are you sure you want to delete this channel?": "您确认要删除此频道吗?", "Are you sure you want to delete this message?": "您确认要删除此消息吗?", "Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "您确认要删除此版本吗?其子版本将重新链接到该版本的上一级。", + "Are you sure you want to delete this?": "", "Are you sure you want to unarchive all archived chats?": "您确认要取消所有已归档的对话吗?", "Arena Models": "启用竞技场匿名评价模型", "Artifacts": "产物", @@ -296,6 +297,7 @@ "Charge Amount Control": "充值金额控制", "Chart new frontiers": "开辟前沿", "Chat": "对话", + "Chat archived.": "对话已归档。", "Chat Background Image": "对话背景图片", "Chat Bubble UI": "以聊天气泡的形式显示对话内容", "Chat Completions": "Chat Completions", @@ -520,6 +522,7 @@ "Defaults": "默认值", "Delete": "删除", "Delete {{count}} Logs Successfully_other": "成功删除 {{count}} 条日志", + "Delete {{name}}": "", "Delete a model": "删除模型", "Delete All": "全部删除", "Delete All Chats": "删除所有对话记录", @@ -614,6 +617,7 @@ "Downloading stats...": "正在下载统计数据...", "Draw": "平局", "Drop any files here to upload": "拖拽文件至此上传", + "Drop files here": "", "Drop files here to upload": "将文件拖到此处即可上传", "DuckDuckGo": "DuckDuckGo", "e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "例如:“30s”,“10m”。有效的时间单位包括:“s”(秒), “m”(分), “h”(时)", @@ -877,6 +881,7 @@ "Fade Effect for Streaming Text": "流式输出内容时启用动态渐显效果", "Failed to add file.": "添加文件失败", "Failed to add members": "添加成员失败", + "Failed to archive chat.": "对话归档失败。", "Failed to attach file": "文件上传失败", "Failed to clear status": "清除状态失败", "Failed to connect to {{URL}} OpenAPI tool server": "连接到 {{URL}} OpenAPI 工具服务器失败", @@ -891,9 +896,11 @@ "Failed to generate title": "生成标题失败", "Failed to import models": "导入模型配置失败", "Failed to load chat preview": "对话预览加载失败", + "Failed to load DOCX file. Please try downloading it instead.": "无法加载 DOCX 文件,请尝试下载后查看。", "Failed to load Excel/CSV file. Please try downloading it instead.": "加载 Excel/CSV 文件失败,请尝试直接下载文件。", "Failed to load file content.": "文件内容加载失败", "Failed to load Interface settings": "“界面设置”数据加载失败", + "Failed to load PPTX file. Please try downloading it instead.": "无法加载 PPTX 文件,请尝试下载后查看。", "Failed to move chat": "移动对话失败", "Failed to process URL: {{url}}": "处理链接失败: {{url}}", "Failed to read clipboard contents": "读取剪贴板内容失败", @@ -949,6 +956,7 @@ "Focus Chat Input": "聚焦对话框", "Folder": "分组", "Folder Background Image": "分组背景图", + "Folder created successfully": "", "Folder deleted successfully": "分组删除成功", "Folder Max File Count": "分组最大文件数量", "Folder name": "文件夹名称", @@ -1329,7 +1337,7 @@ "More options": "更多选项", "More Options": "更多选项", "Move": "移动", - "Moved {{name}}": "", + "Moved {{name}}": "移动“{{name}}”成功", "My Terminal": "我的终端", "Name": "名称", "Name and ID are required, please fill them out": "名称和 ID 是必填项,请填写。", @@ -1374,11 +1382,13 @@ "No file selected": "未选中文件", "No files found": "未找到文件", "No files in this knowledge base.": "此知识库中没有文件。", + "No files yet. Upload files or run Python code to create them.": "", "No functions found": "未找到函数", "No groups found": "暂无权限组", "No history available": "暂无历史记录", "No HTML, CSS, or JavaScript content found.": "未找到 HTML、CSS 或 JavaScript 内容。", "No inference engine with management support found": "未找到支持管理的推理引擎", + "No kernel": "未找到内核", "No knowledge bases found.": "未找到知识库", "No knowledge found": "未找到知识", "No Log": "暂无日志", @@ -1396,6 +1406,7 @@ "No results": "未找到结果", "No results found": "未找到结果", "No search query generated": "未生成搜索查询", + "No servers detected": "未检测到任何服务器", "No skills found": "没有找到技能", "No source available": "没有可用引用来源", "No sources found": "未找到任何引用来源", @@ -1562,6 +1573,7 @@ "Please use a private key in PKCS#1 format. You can convert it using a format-conversion tool.": "请使用 PKCS1 格式的密钥,新生成的密钥需要通过工具转换", "Please wait until all files are uploaded.": "请等待所有文件上传完毕。", "Port": "端口", + "Ports": "端口", "Positive attitude": "态度积极", "Prefer not to say": "暂不透露", "Prefix ID": "模型 ID 前缀", @@ -1599,6 +1611,7 @@ "Pull \"{{searchValue}}\" from Ollama.com": "从 Ollama.com 下载 “{{searchValue}}”", "Pull a model from Ollama.com": "从 Ollama.com 下载模型", "Pull Model": "下载模型", + "Pyodide file browser": "", "QRCode": "二维码", "Query Generation Prompt": "查询生成提示词", "Querying": "查询中", @@ -1680,6 +1693,7 @@ "Response splitting": "拆分回答", "Response Watermark": "复制时添加水印", "Responses": "Responses", + "Restart": "重启", "Result": "结果", "RESULT": "结果", "Retrieval": "检索", @@ -1691,6 +1705,7 @@ "Role": "角色", "RTL": "从右至左", "Run": "运行", + "Run All": "运行全部", "Running": "运行中", "Running...": "运行中...", "Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "并行运行嵌入任务以加快处理速度。如果遇到限速问题,请关闭此选项。", @@ -1889,6 +1904,7 @@ "Start a new conversation": "开始新对话", "Start of the channel": "频道起点", "Start Tag": "起始标签", + "Starting kernel...": "正在启动内核...", "Status": "状态", "Status cleared successfully": "状态已清除", "Status updated successfully": "状态已更新", @@ -2258,6 +2274,8 @@ "You're now logged in.": "已登录。", "Your Account": "您的账号", "Your account status is currently pending activation.": "您的账号当前状态为待激活", + "Your browser does not support the audio tag.": "您的浏览器不支持播放音频。", + "Your browser does not support the video tag.": "您的浏览器不支持播放视频。", "Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "您的全部捐款将直接给到插件开发者,Open WebUI 不会收取任何分成。但众筹平台可能会有服务费。", "Your message text or inputs": "您的消息文本或输入", "Your usage stats have been successfully synced.": "已成功同步您的使用统计数据。", diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index cab868d1b9..8365d3823c 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -72,6 +72,9 @@ export const functions = writable(null); export const toolServers = writable([]); export const terminalServers = writable([]); +// Persistent Pyodide worker for code interpreter FS +export const pyodideWorker: Writable = writable(null); + export const banners: Writable = writable([]); export const settings: Writable = writable({}); diff --git a/src/lib/utils/codeHighlight.ts b/src/lib/utils/codeHighlight.ts new file mode 100644 index 0000000000..23861bc77b --- /dev/null +++ b/src/lib/utils/codeHighlight.ts @@ -0,0 +1,188 @@ +/** + * Map file extensions to Shiki language identifiers. + * Only extensions whose Shiki lang id differs from the extension itself need explicit entries. + */ +const EXT_OVERRIDE: Record = { + py: 'python', + js: 'javascript', + ts: 'typescript', + jsx: 'jsx', + tsx: 'tsx', + rb: 'ruby', + rs: 'rust', + kt: 'kotlin', + cs: 'csharp', + fs: 'fsharp', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + yml: 'yaml', + md: 'markdown', + mdx: 'mdx', + dockerfile: 'dockerfile', + tf: 'terraform', + hcl: 'hcl', + ex: 'elixir', + exs: 'elixir', + erl: 'erlang', + hs: 'haskell', + ml: 'ocaml', + mli: 'ocaml', + pl: 'perl', + pm: 'perl', + r: 'r', + m: 'objective-c', + mm: 'objective-cpp', + h: 'c', + hpp: 'cpp', + cc: 'cpp', + cxx: 'cpp', + proto: 'proto', + nim: 'nim', + zig: 'zig', + v: 'v', + svelte: 'svelte', + vue: 'vue', + astro: 'astro', + prisma: 'prisma', + graphql: 'graphql', + gql: 'graphql', + jsonc: 'jsonc', + jsonl: 'jsonl' +}; + +// Common extensions that exactly match their Shiki language ID. +// This replaces the runtime `bundledLanguages` import from shiki, which +// pulled ~5-10MB of JavaScript into the initial page load just so +// isCodeFile() could check extension support. +const KNOWN_LANG_IDS = new Set([ + 'ada', + 'awk', + 'bat', + 'c', + 'cmake', + 'clojure', + 'cpp', + 'crystal', + 'css', + 'd', + 'dart', + 'diff', + 'elixir', + 'elm', + 'erlang', + 'fish', + 'gleam', + 'glsl', + 'go', + 'groovy', + 'haml', + 'haskell', + 'hlsl', + 'html', + 'ini', + 'java', + 'javascript', + 'json', + 'json5', + 'jsonc', + 'jsx', + 'julia', + 'kotlin', + 'latex', + 'less', + 'lisp', + 'log', + 'lua', + 'make', + 'markdown', + 'matlab', + 'mdx', + 'mojo', + 'nim', + 'nix', + 'nushell', + 'ocaml', + 'pascal', + 'perl', + 'php', + 'postcss', + 'powershell', + 'prisma', + 'prolog', + 'proto', + 'pug', + 'python', + 'r', + 'ruby', + 'rust', + 'sass', + 'scala', + 'scheme', + 'scss', + 'solidity', + 'sql', + 'svelte', + 'swift', + 'tcl', + 'terraform', + 'tex', + 'toml', + 'tsx', + 'typescript', + 'typst', + 'v', + 'vb', + 'verilog', + 'vhdl', + 'vue', + 'wasm', + 'wgsl', + 'xml', + 'yaml', + 'zig' +]); + +/** + * Resolve a file extension to a Shiki language id, or null if not supported. + */ +export function extToLang(ext: string): string | null { + const lower = ext.toLowerCase(); + // explicit override first + if (EXT_OVERRIDE[lower]) return EXT_OVERRIDE[lower]; + // if the extension itself is a known language id (e.g. 'go', 'rust', 'sql', 'toml', ...) + if (KNOWN_LANG_IDS.has(lower)) return lower; + return null; +} + +/** + * Returns true if the given file path has a code-file extension that Shiki can highlight. + */ +export function isCodeFile(path: string | null): boolean { + if (!path) return false; + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + return extToLang(ext) !== null; +} + +/** + * Highlight code using Shiki with dual light/dark themes via CSS variables. + * Returns an HTML string. Throws on failure. + * + * Shiki is loaded on demand (dynamic import) to avoid pulling ~5-10MB of + * JavaScript into the initial page bundle. Since this function is already + * async, callers are completely unaffected by the change. + */ +export async function highlightCode(code: string, filePath: string): Promise { + const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; + const lang = extToLang(ext) ?? 'text'; + + const { codeToHtml } = await import('shiki'); + return await codeToHtml(code, { + lang, + themes: { + light: 'github-light', + dark: 'github-dark' + }, + defaultColor: 'light' + }); +} diff --git a/src/lib/utils/excelToTable.ts b/src/lib/utils/excelToTable.ts new file mode 100644 index 0000000000..3f7f8004a2 --- /dev/null +++ b/src/lib/utils/excelToTable.ts @@ -0,0 +1,88 @@ +/** + * Shared Excel → HTML table renderer. + * + * Converts a worksheet to a styled HTML table with: + * - Column letter headers (A, B, C…) + * - Row numbers + * - Proper empty cell handling + * - Sanitized output + */ + +import type { WorkSheet } from 'xlsx'; + +/** Convert column index (0-based) to Excel-style letter (A, B, …, Z, AA, AB, …) */ +const colLetter = (i: number): string => { + let s = ''; + let n = i; + while (n >= 0) { + s = String.fromCharCode(65 + (n % 26)) + s; + n = Math.floor(n / 26) - 1; + } + return s; +}; + +/** Escape HTML entities */ +const esc = (v: unknown): string => { + if (v === null || v === undefined || v === '') return ' '; + return String(v).replace(/&/g, '&').replace(//g, '>'); +}; + +export interface ExcelTableResult { + html: string; + rowCount: number; + colCount: number; +} + +/** + * Render a worksheet as an HTML table string. + * Uses sheet_to_json with header:1 for a raw 2D array. + */ +export async function excelToTable(worksheet: WorkSheet): Promise { + const XLSX = await import('xlsx'); + const rows: unknown[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); + + if (rows.length === 0) { + return { + html: '
 
', + rowCount: 0, + colCount: 0 + }; + } + + // Determine column count from the widest row + const colCount = rows.reduce((max, row) => Math.max(max, row.length), 0); + const rowCount = rows.length; + + const parts: string[] = []; + parts.push(''); + + // Column letter header row + parts.push(''); + parts.push(''); // corner cell + for (let c = 0; c < colCount; c++) { + parts.push(``); + } + parts.push(''); + + // Data rows + parts.push(''); + for (let r = 0; r < rowCount; r++) { + const row = rows[r]; + parts.push(''); + parts.push(``); + for (let c = 0; c < colCount; c++) { + const val = c < row.length ? row[c] : ''; + const isNum = typeof val === 'number'; + parts.push(`${esc(val)}`); + } + parts.push(''); + } + parts.push('
${colLetter(c)}
${r + 1}
'); + + const DOMPurify = (await import('dompurify')).default; + return { + html: DOMPurify.sanitize(parts.join('')), + rowCount, + colCount + }; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 3c42370063..61ecb75c8b 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -275,11 +275,50 @@ export const canvasPixelTest = () => { return true; }; +let resizeImageWarmupDone = false; +/** + * Draws an image to a canvas at the given dimensions and returns a data URL. + * On mobile, the first export uses toBlob (avoids black image on Android); later exports use toDataURL. + */ +async function resizeImageToDataURL( + img: HTMLImageElement, + width: number, + height: number, + mimeType = 'image/jpeg' +): Promise { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.getContext('2d')?.drawImage(img, 0, 0, width, height); + + const toDataURL = () => canvas.toDataURL(mimeType); + + if ( + !resizeImageWarmupDone && + canvas.toBlob && + /android|iphone|ipad|ipod/i.test(navigator?.userAgent) + ) { + resizeImageWarmupDone = true; + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { + resolve(toDataURL()); + return; + } + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => resolve(toDataURL()); + reader.readAsDataURL(blob); + }, mimeType); + }); + } + return Promise.resolve(toDataURL()); +} + export const compressImage = async (imageUrl, maxWidth, maxHeight) => { return new Promise((resolve, reject) => { const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); + img.onload = async () => { let width = img.width; let height = img.height; @@ -322,16 +361,8 @@ export const compressImage = async (imageUrl, maxWidth, maxHeight) => { height = maxHeight; } - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext('2d'); - context.drawImage(img, 0, 0, width, height); - - // Get compressed image URL - const mimeType = imageUrl.match(/^data:([^;]+);/)?.[1]; - const compressedUrl = canvas.toDataURL(mimeType); - resolve(compressedUrl); + const mimeType = imageUrl.match(/^data:([^;]+);/)?.[1] ?? 'image/jpeg'; + resolve(await resizeImageToDataURL(img, width, height, mimeType)); }; img.onerror = (error) => reject(error); img.src = imageUrl; @@ -956,6 +987,16 @@ export const extractSentencesForAudio = (text: string) => { }; export const getMessageContentParts = (content: string, splitOn: string = 'punctuation') => { + // Strip
blocks directly on the full string before any + // code-block-aware processing. removeAllDetails (which callers use) + // applies the regex via replaceOutsideCode, which splits on triple- + // backtick code fences first. If a
block contains code + // fences (e.g. reasoning with code examples), the opening and + // closing tags land in separate segments and the regex fails, + // leaking thinking content into TTS. Applying the strip here on + // the full string catches those cases. (Fixes #22197) + content = content.replace(/]*>[\s\S]*?<\/details>/gi, ''); + const messageContentParts: string[] = []; switch (splitOn) { @@ -1171,19 +1212,19 @@ export const getWeekday = () => { }; export const createMessagesList = (history, messageId) => { - if (messageId === null) { - return []; - } + const list = []; + let currentId = messageId; - const message = history.messages[messageId]; - if (message === undefined) { - return []; - } - if (message?.parentId) { - return [...createMessagesList(history, message.parentId), message]; - } else { - return [message]; + while (currentId !== null && currentId !== undefined) { + const message = history.messages[currentId]; + if (message === undefined) { + break; + } + list.push(message); + currentId = message.parentId; } + + return list.reverse(); }; export const formatFileSize = (size) => { @@ -1638,6 +1679,10 @@ export const renderVegaVisualization = async (spec: string, i18n?: any) => { }; export const getCodeBlockContents = (content: string): object => { + // Strip thinking/reasoning and other detail blocks before extracting code + // to prevent code inside
from being treated as artifacts + content = removeAllDetails(content); + const codeBlockContents = content.match(/```[\s\S]*?```/g); let codeBlocks = []; diff --git a/src/lib/utils/marked/katex-extension.ts b/src/lib/utils/marked/katex-extension.ts index dd755066ce..13860fab30 100644 --- a/src/lib/utils/marked/katex-extension.ts +++ b/src/lib/utils/marked/katex-extension.ts @@ -13,6 +13,12 @@ const ALLOWED_SURROUNDING_CHARS = '\\s。,、、;;„“‘’“”()「」『』[]《》【】‹›«»…⋯::?!~⇒?!-\\/:-@\\[-`{-~\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}'; // Modified to fit more formats in different languages. Originally: '\\s?。,、;!-\\/:-@\\[-`{-~\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}'; +// Pre-compile the surrounding character regex once at module load time. +// This regex uses Unicode property escapes (\p{Script=Han}, etc.) which are +// extremely expensive to compile - doing so on every call caused ~87% of +// markdown rendering time to be spent in KaTeX regex compilation. +const ALLOWED_SURROUNDING_CHARS_REGEX = new RegExp(`[${ALLOWED_SURROUNDING_CHARS}]`, 'u'); + // const DELIMITER_LIST = [ // { left: '$$', right: '$$', display: false }, // { left: '$', right: '$', display: false }, @@ -67,48 +73,31 @@ export default function (options = {}) { } function katexStart(src, displayMode: boolean) { - const ruleReg = displayMode ? blockRule : inlineRule; + for (let i = 0; i < src.length; i++) { + const ch = src.charCodeAt(i); - let indexSrc = src; - - while (indexSrc) { - let index = -1; - let startIndex = -1; - let startDelimiter = ''; - let endDelimiter = ''; - for (const delimiter of DELIMITER_LIST) { - if (delimiter.display !== displayMode) { + if (ch === 36 /* $ */) { + // Display mode requires $$, skip single $ for display + if (displayMode && src.charAt(i + 1) !== '$') { continue; } - - startIndex = indexSrc.indexOf(delimiter.left); - if (startIndex === -1) { - continue; + if (i === 0 || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i - 1))) { + return i; } - - index = startIndex; - startDelimiter = delimiter.left; - endDelimiter = delimiter.right; - } - - if (index === -1) { - return; - } - - // Check if the delimiter is preceded by a special character. - // If it does, then it's potentially a math formula. - const f = - index === 0 || - indexSrc.charAt(index - 1).match(new RegExp(`[${ALLOWED_SURROUNDING_CHARS}]`, 'u')); - if (f) { - const possibleKatex = indexSrc.substring(index); - - if (possibleKatex.match(ruleReg)) { - return index; + } else if (ch === 92 /* \ */) { + const next = src.charAt(i + 1); + // Only consider \ if followed by a valid math delimiter start + if (displayMode) { + // Display: \[ or \begin{equation} + if (next !== '[' && next !== 'b') continue; + } else { + // Inline: \( or \ce{ or \pu{ + if (next !== '(' && next !== 'c' && next !== 'p') continue; + } + if (i === 0 || ALLOWED_SURROUNDING_CHARS_REGEX.test(src.charAt(i - 1))) { + return i; } } - - indexSrc = indexSrc.substring(index + startDelimiter.length).replace(endDelimiter, ''); } } diff --git a/src/lib/utils/pptxToHtml.ts b/src/lib/utils/pptxToHtml.ts new file mode 100644 index 0000000000..045ac70001 --- /dev/null +++ b/src/lib/utils/pptxToHtml.ts @@ -0,0 +1,257 @@ +/** + * Lightweight PPTX → Image renderer. + * + * Extracts text and images from each slide and renders them + * directly to canvas, returning PNG data URLs. + * + * Uses jszip (dynamically imported) and the browser Canvas 2D API. + * No theme resolution, charts, SmartArt, or animations — preview only. + */ + +const EMU_PER_PX = 9525; +const emuToPx = (emu: number) => Math.round(emu / EMU_PER_PX); + +const parseEmu = (val: string | null | undefined): number => (val ? parseInt(val, 10) || 0 : 0); + +/** Load a data URI into an Image element and wait for it. */ +const loadImage = (src: string): Promise => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = src; + }); + +/** + * Convert PPTX ArrayBuffer → array of PNG data URL strings, one per slide. + */ +export async function pptxToImages( + buffer: ArrayBuffer +): Promise<{ images: string[]; width: number; height: number }> { + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(buffer); + + // ── Read slide dimensions from presentation.xml ────────────────── + let slideW = 960; + let slideH = 540; + const presXml = zip.file('ppt/presentation.xml'); + if (presXml) { + const presText = await presXml.async('text'); + const presDoc = new DOMParser().parseFromString(presText, 'application/xml'); + const sldSz = presDoc.getElementsByTagName('p:sldSz')[0]; + if (sldSz) { + slideW = emuToPx(parseEmu(sldSz.getAttribute('cx'))); + slideH = emuToPx(parseEmu(sldSz.getAttribute('cy'))); + } + } + + // ── Collect media files (images) as base64 data URIs ───────────── + const media: Record = {}; + const mediaFiles = Object.keys(zip.files).filter((f) => f.startsWith('ppt/media/')); + await Promise.all( + mediaFiles.map(async (path) => { + const file = zip.file(path); + if (!file) return; + const base64 = await file.async('base64'); + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + const mime = + ext === 'png' + ? 'image/png' + : ext === 'gif' + ? 'image/gif' + : ext === 'svg' + ? 'image/svg+xml' + : ext === 'emf' || ext === 'wmf' + ? 'image/x-emf' + : 'image/jpeg'; + media[path] = `data:${mime};base64,${base64}`; + }) + ); + + // ── Discover slide files ───────────────────────────────────────── + const slideFiles = Object.keys(zip.files) + .filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f)) + .sort((a, b) => { + const na = parseInt(a.match(/slide(\d+)/)?.[1] ?? '0'); + const nb = parseInt(b.match(/slide(\d+)/)?.[1] ?? '0'); + return na - nb; + }); + + const images: string[] = []; + + for (const slidePath of slideFiles) { + const slideText = await zip.file(slidePath)!.async('text'); + const slideDoc = new DOMParser().parseFromString(slideText, 'application/xml'); + + // Load relationship file for this slide to resolve image references + const slideNum = slidePath.match(/slide(\d+)/)?.[1]; + const relsPath = `ppt/slides/_rels/slide${slideNum}.xml.rels`; + const rels: Record = {}; + const relsFile = zip.file(relsPath); + if (relsFile) { + const relsText = await relsFile.async('text'); + const relsDoc = new DOMParser().parseFromString(relsText, 'application/xml'); + const relEls = relsDoc.getElementsByTagName('Relationship'); + for (let i = 0; i < relEls.length; i++) { + const rel = relEls[i]; + const id = rel.getAttribute('Id') ?? ''; + const target = rel.getAttribute('Target') ?? ''; + if (target.startsWith('../')) { + rels[id] = 'ppt/' + target.replace('../', ''); + } else { + rels[id] = target; + } + } + } + + // ── Create canvas and render slide ─────────────────────────── + const canvas = document.createElement('canvas'); + canvas.width = slideW; + canvas.height = slideH; + const ctx = canvas.getContext('2d')!; + + // White background + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, slideW, slideH); + + const spTree = slideDoc.getElementsByTagName('p:spTree')[0]; + if (!spTree) { + images.push(canvas.toDataURL('image/png')); + continue; + } + + const shapes = [ + ...Array.from(spTree.getElementsByTagName('p:sp')), + ...Array.from(spTree.getElementsByTagName('p:pic')) + ]; + + for (const shape of shapes) { + const xfrm = + shape.getElementsByTagName('a:xfrm')[0] ?? shape.getElementsByTagName('p:xfrm')[0]; + if (!xfrm) continue; + + const off = xfrm.getElementsByTagName('a:off')[0]; + const ext = xfrm.getElementsByTagName('a:ext')[0]; + if (!off || !ext) continue; + + const x = emuToPx(parseEmu(off.getAttribute('x'))); + const y = emuToPx(parseEmu(off.getAttribute('y'))); + const w = emuToPx(parseEmu(ext.getAttribute('cx'))); + const h = emuToPx(parseEmu(ext.getAttribute('cy'))); + + if (w === 0 && h === 0) continue; + + // ── Picture ────────────────────────────────────────────── + const blipFill = shape.getElementsByTagName('p:blipFill')[0]; + if (blipFill) { + const blip = blipFill.getElementsByTagName('a:blip')[0]; + if (blip) { + const rEmbed = blip.getAttribute('r:embed') ?? ''; + const mediaPath = rels[rEmbed]; + const dataUri = mediaPath ? media[mediaPath] : ''; + if (dataUri && !dataUri.includes('image/x-emf')) { + try { + const img = await loadImage(dataUri); + ctx.drawImage(img, x, y, w, h); + } catch { + // Skip images that fail to load + } + } + } + continue; + } + + // ── Text shape ─────────────────────────────────────────── + const txBody = shape.getElementsByTagName('p:txBody')[0]; + if (!txBody) continue; + + ctx.save(); + ctx.rect(x, y, w, h); + ctx.clip(); + + const paragraphs = txBody.getElementsByTagName('a:p'); + let cursorY = y; + const defaultFontSize = 12; + + for (let pi = 0; pi < paragraphs.length; pi++) { + const para = paragraphs[pi]; + const runs = para.getElementsByTagName('a:r'); + + if (runs.length === 0) { + cursorY += defaultFontSize * 1.5; + continue; + } + + // Calculate max font size in this paragraph for line height + let maxFontPt = defaultFontSize; + for (let ri = 0; ri < runs.length; ri++) { + const rPr = runs[ri].getElementsByTagName('a:rPr')[0]; + if (rPr) { + const sz = rPr.getAttribute('sz'); + if (sz) { + const pt = parseInt(sz, 10) / 100; + if (pt > maxFontPt) maxFontPt = pt; + } + } + } + + const lineHeight = maxFontPt * 1.4; + cursorY += maxFontPt; // baseline offset + + let cursorX = x + 4; // small left padding + + for (let ri = 0; ri < runs.length; ri++) { + const run = runs[ri]; + const rPr = run.getElementsByTagName('a:rPr')[0]; + const text = run.getElementsByTagName('a:t')[0]?.textContent ?? ''; + if (!text) continue; + + let fontPt = defaultFontSize; + let bold = false; + let italic = false; + let color = '#000000'; + + if (rPr) { + if (rPr.getAttribute('b') === '1') bold = true; + if (rPr.getAttribute('i') === '1') italic = true; + const sz = rPr.getAttribute('sz'); + if (sz) fontPt = parseInt(sz, 10) / 100; + const solidFill = rPr.getElementsByTagName('a:solidFill')[0]; + if (solidFill) { + const srgb = solidFill.getElementsByTagName('a:srgbClr')[0]; + if (srgb) { + const val = srgb.getAttribute('val'); + if (val) color = `#${val}`; + } + } + } + + ctx.font = `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${fontPt}pt Calibri, Arial, sans-serif`; + ctx.fillStyle = color; + ctx.textBaseline = 'alphabetic'; + + // Simple word-wrap within the shape bounds + const words = text.split(/(\s+)/); + for (const word of words) { + const metrics = ctx.measureText(word); + if (cursorX + metrics.width > x + w && cursorX > x + 4) { + cursorX = x + 4; + cursorY += lineHeight; + } + if (cursorY > y + h) break; + ctx.fillText(word, cursorX, cursorY); + cursorX += metrics.width; + } + } + + cursorY += lineHeight * 0.4; // paragraph spacing + } + + ctx.restore(); + } + + images.push(canvas.toDataURL('image/png')); + } + + return { images, width: slideW, height: slideH }; +} diff --git a/src/lib/workers/pyodide.worker.ts b/src/lib/workers/pyodide.worker.ts index 221effca5e..7ef3916731 100644 --- a/src/lib/workers/pyodide.worker.ts +++ b/src/lib/workers/pyodide.worker.ts @@ -13,6 +13,12 @@ declare global { } } +// --------------------------------------------------------------------------- +// Pyodide bootstrap +// --------------------------------------------------------------------------- + +let pyodideReady: Promise | null = null; + async function loadPyodideAndPackages(packages: string[] = []) { self.stdout = null; self.stderr = null; @@ -40,41 +46,148 @@ async function loadPyodideAndPackages(packages: string[] = []) { packages: ['micropip'] }); - const mountDir = '/mnt'; - self.pyodide.FS.mkdirTree(mountDir); - // self.pyodide.FS.mount(self.pyodide.FS.filesystems.IDBFS, {}, mountDir); - - // // Load persisted files from IndexedDB (Initial Sync) - // await new Promise((resolve, reject) => { - // self.pyodide.FS.syncfs(true, (err) => { - // if (err) { - // console.error('Error syncing from IndexedDB:', err); - // reject(err); - // } else { - // console.log('Successfully loaded from IndexedDB.'); - // resolve(); - // } - // }); - // }); + // Create the upload directory and mount IDBFS for persistence + const uploadDir = '/mnt/uploads'; + self.pyodide.FS.mkdirTree(uploadDir); + self.pyodide.FS.mount(self.pyodide.FS.filesystems.IDBFS, {}, '/mnt'); - const micropip = self.pyodide.pyimport('micropip'); + // Load persisted files from IndexedDB + await new Promise((resolve) => { + (self.pyodide.FS as any).syncfs(true, (err: Error | null) => { + if (err) { + console.error('Error syncing from IndexedDB:', err); + } + // Always resolve — missing data is fine on first run + resolve(); + }); + }); - // await micropip.set_index_urls('https://pypi.org/pypi/{package_name}/json'); + // Ensure /mnt/uploads still exists after sync (first-time init) + try { + self.pyodide.FS.stat(uploadDir); + } catch { + self.pyodide.FS.mkdirTree(uploadDir); + } + + const micropip = self.pyodide.pyimport('micropip'); await micropip.install(packages); } -self.onmessage = async (event) => { - const { id, code, ...context } = event.data; +/** + * Ensure Pyodide is loaded. On the first call, loads and installs packages. + * Subsequent calls reuse the already-loaded instance (persistent worker). + */ +async function ensurePyodide(packages: string[] = []) { + if (!pyodideReady) { + pyodideReady = loadPyodideAndPackages(packages); + } + await pyodideReady; + + // Install any additional packages not loaded on init + if (packages.length > 0 && self.pyodide) { + const micropip = self.pyodide.pyimport('micropip'); + await micropip.install(packages); + } +} + +/** + * Persist the in-memory FS to IndexedDB (fire-and-forget with logging). + */ +function persistFS() { + if (!self.pyodide) return; + (self.pyodide.FS as any).syncfs(false, (err: Error | null) => { + if (err) { + console.error('Error syncing to IndexedDB:', err); + } else { + console.log('Successfully synced to IndexedDB.'); + } + }); +} - console.log(event.data); +// --------------------------------------------------------------------------- +// FS operations +// --------------------------------------------------------------------------- - // The worker copies the context in its own "memory" (an object mapping name to values) - for (const key of Object.keys(context)) { - self[key] = context[key]; +function fsUploadFiles(files: { name: string; data: ArrayBuffer }[], dir = '/mnt/uploads') { + try { + self.pyodide.FS.stat(dir); + } catch { + self.pyodide.FS.mkdirTree(dir); } - // make sure loading is done - await loadPyodideAndPackages(self.packages); + for (const file of files) { + self.pyodide.FS.writeFile(`${dir}/${file.name}`, new Uint8Array(file.data)); + } +} + +function fsList(path: string) { + const entries: { name: string; type: 'file' | 'directory'; size: number }[] = []; + try { + const items = self.pyodide.FS.readdir(path).filter((n: string) => n !== '.' && n !== '..'); + for (const name of items) { + try { + const stat = self.pyodide.FS.stat(`${path}/${name}`); + const isDir = self.pyodide.FS.isDir(stat.mode); + entries.push({ + name, + type: isDir ? 'directory' : 'file', + size: isDir ? 0 : stat.size + }); + } catch { + // skip inaccessible entries + } + } + } catch { + // directory doesn't exist + } + return entries; +} + +function fsRead(path: string): ArrayBuffer { + const data: Uint8Array = (self.pyodide.FS as any).readFile(path) as Uint8Array; + return data.buffer as ArrayBuffer; +} + +function fsDelete(path: string) { + try { + const stat = self.pyodide.FS.stat(path); + if (self.pyodide.FS.isDir(stat.mode)) { + // Recursively delete directory contents + const items = self.pyodide.FS.readdir(path).filter((n: string) => n !== '.' && n !== '..'); + for (const item of items) { + fsDelete(`${path}/${item}`); + } + self.pyodide.FS.rmdir(path); + } else { + self.pyodide.FS.unlink(path); + } + } catch { + // already gone + } +} + +function fsMkdir(path: string) { + self.pyodide.FS.mkdirTree(path); +} + +// --------------------------------------------------------------------------- +// Code execution +// --------------------------------------------------------------------------- + +async function executeCode( + id: string, + code: string, + files?: { name: string; data: ArrayBuffer }[] +) { + self.stdout = null; + self.stderr = null; + self.result = null; + + // Upload any accompanying files before execution + if (files && files.length > 0) { + fsUploadFiles(files); + persistFS(); + } try { // check if matplotlib is imported in the code @@ -113,25 +226,94 @@ matplotlib.pyplot.show = show`); console.log('Python result:', self.result); - // Persist any changes to IndexedDB - // await new Promise((resolve, reject) => { - // self.pyodide.FS.syncfs(false, (err) => { - // if (err) { - // console.error('Error syncing to IndexedDB:', err); - // reject(err); - // } else { - // console.log('Successfully synced to IndexedDB.'); - // resolve(); - // } - // }); - // }); - } catch (error) { - self.stderr = error.toString(); + // Persist any files the code may have written + persistFS(); + } catch (error: unknown) { + self.stderr = error instanceof Error ? error.message : String(error); } self.postMessage({ id, result: self.result, stdout: self.stdout, stderr: self.stderr }); +} + +// --------------------------------------------------------------------------- +// Message handler +// --------------------------------------------------------------------------- + +self.onmessage = async (event) => { + const data = event.data; + const { id, type } = data; + + // Backward compatibility: messages without a `type` field are execute requests + if (!type || type === 'execute') { + const { code, files, ...context } = data; + + // Copy context keys (packages, etc.) into worker scope + for (const key of Object.keys(context)) { + if (key !== 'id' && key !== 'type') { + self[key] = context[key]; + } + } + + await ensurePyodide(self.packages); + await executeCode(id, code, files); + return; + } + + // FS operations require Pyodide to be loaded + await ensurePyodide(); + + switch (type) { + case 'fs:upload': { + const { files, dir } = data; + fsUploadFiles(files, dir); + persistFS(); + self.postMessage({ id, type: 'fs:upload', success: true }); + break; + } + + case 'fs:list': { + const entries = fsList(data.path); + self.postMessage({ id, type: 'fs:list', entries }); + break; + } + + case 'fs:read': { + try { + const buffer = fsRead(data.path); + self.postMessage({ id, type: 'fs:read', data: buffer }, { transfer: [buffer] }); + } catch (err: unknown) { + self.postMessage({ + id, + type: 'fs:read', + error: err instanceof Error ? err.message : String(err) + }); + } + break; + } + + case 'fs:delete': { + fsDelete(data.path); + persistFS(); + self.postMessage({ id, type: 'fs:delete', success: true }); + break; + } + + case 'fs:mkdir': { + fsMkdir(data.path); + persistFS(); + self.postMessage({ id, type: 'fs:mkdir', success: true }); + break; + } + + default: + console.warn('Unknown message type:', type); + } }; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function processResult(result: any): any { // Catch and always return JSON-safe string representations try { @@ -167,9 +349,9 @@ function processResult(result: any): any { } // Stringify anything that's left (e.g., Proxy objects that cannot be directly processed) return JSON.stringify(result); - } catch (err) { + } catch (err: unknown) { // In case something unexpected happens, we return a stringified fallback - return `[processResult error]: ${err.message || err.toString()}`; + return `[processResult error]: ${err instanceof Error ? err.message : String(err)}`; } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 42768ca99d..36fd7b22d5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -34,8 +34,10 @@ terminalServers, showControls, showFileNavPath, - showFileNavDir + showFileNavDir, + pyodideWorker } from '$lib/stores'; + import { getFileContentById } from '$lib/apis/files'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { beforeNavigate } from '$app/navigation'; @@ -184,7 +186,20 @@ }); }; - const executePythonAsWorker = async (id, code, cb) => { + /** + * Get or create the persistent Pyodide worker. + * The worker persists across executions so the virtual FS (IDBFS) is preserved. + */ + const getOrCreateWorker = () => { + let worker = $pyodideWorker; + if (!worker) { + worker = new PyodideWorker(); + pyodideWorker.set(worker); + } + return worker; + }; + + const executePythonAsWorker = async (id, code, cb, files = []) => { let result = null; let stdout = null; let stderr = null; @@ -206,19 +221,44 @@ /\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null ].filter(Boolean); - const pyodideWorker = new PyodideWorker(); + const worker = getOrCreateWorker(); + + // Fetch file content from the server and prepare for the worker + let filePayloads = []; + if (files && files.length > 0) { + for (const file of files) { + try { + const fileId = file?.id; + const fileName = file?.filename || file?.name || 'file'; + if (fileId) { + const content = await getFileContentById(fileId); + if (content) { + filePayloads.push({ name: fileName, data: content }); + } + } + } catch (e) { + console.error('Failed to fetch file for Pyodide:', e); + } + } + } - pyodideWorker.postMessage({ + worker.postMessage({ + type: 'execute', id: id, code: code, - packages: packages + packages: packages, + files: filePayloads.length > 0 ? filePayloads : undefined }); - setTimeout(() => { + // Timeout for this specific execution (not the worker itself) + let timeoutId = setTimeout(() => { if (executing) { executing = false; stderr = 'Execution Time Limit Exceeded'; - pyodideWorker.terminate(); + + // Terminate and recreate the worker on timeout + worker.terminate(); + pyodideWorker.set(null); if (cb) { cb( @@ -237,11 +277,18 @@ } }, 60000); - pyodideWorker.onmessage = (event) => { - console.log('pyodideWorker.onmessage', event); - const { id, ...data } = event.data; + // Use addEventListener so multiple concurrent executions don't clobber each other + const onMessage = (event) => { + const { id: eventId, ...data } = event.data; + // Only handle responses for this execution ID + if (eventId !== id) return; + // Ignore FS responses (they use a type field) + if (data.type && data.type.startsWith('fs:')) return; - console.log(id, data); + console.log('pyodideWorker.onmessage', event); + clearTimeout(timeoutId); + worker.removeEventListener('message', onMessage); + worker.removeEventListener('error', onError); data['stdout'] && (stdout = data['stdout']); data['stderr'] && (stderr = data['stderr']); @@ -265,8 +312,11 @@ executing = false; }; - pyodideWorker.onerror = (event) => { + const onError = (event) => { console.log('pyodideWorker.onerror', event); + clearTimeout(timeoutId); + worker.removeEventListener('message', onMessage); + worker.removeEventListener('error', onError); if (cb) { cb( @@ -284,6 +334,9 @@ } executing = false; }; + + worker.addEventListener('message', onMessage); + worker.addEventListener('error', onError); }; const resolveToolServer = (serverUrl) => { @@ -423,7 +476,7 @@ } else if (data?.session_id === $socket.id) { if (type === 'execute:python') { console.log('execute:python', data); - executePythonAsWorker(data.id, data.code, cb); + executePythonAsWorker(data.id, data.code, cb, data.files || []); } else if (type === 'execute:tool') { console.log('execute:tool', data); executeTool(data, cb); diff --git a/static/sql.js/sql-wasm.wasm b/static/sql.js/sql-wasm.wasm new file mode 100755 index 0000000000..b32b66473d Binary files /dev/null and b/static/sql.js/sql-wasm.wasm differ