Merge pull request #32511 from open-webui/dev
Some checks are pending
Python CI / Ruff Format (3.12) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Unit Tests (push) Waiting to run
Frontend Build / Format & Build (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Release to PyPI / release (push) Waiting to run
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Release / publish (push) Waiting to run

0.12.3
This commit is contained in:
Tim Baek 2026-10-16 06:46:08 -05:00 • committed by GitHub
commit 91ea4e3421
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
209 changed files with 25223 additions and 3284 deletions

View file

@ -6,7 +6,7 @@ OPENAI_API_BASE_URL=''
OPENAI_API_KEY=''
# With WEBSOCKET_MANAGER=redis, room channels require PUBLISH, SUBSCRIBE and
# PSUBSCRIBE permissions plus channel ACLs &socketio and &socketio#*.
# UNSUBSCRIBE/PING permissions plus channel ACLs &socketio and &socketio#*.
# Set false for shared-channel delivery. This must match on every instance;
# changing it requires a full fleet restart, not a rolling restart.
# WEBSOCKET_REDIS_ROOM_CHANNELS=true

View file

@ -11,3 +11,7 @@ node_modules
pnpm-lock.yaml
package-lock.json
yarn.lock
# Generated and checksum-verified crypto distribution
src/lib/encryption/vendor/
crypto/encryption/target/

View file

@ -311,4 +311,7 @@ cypress/screenshots
/static/*
/static/*
# Generated and checksum-verified crypto distribution
src/lib/encryption/vendor/
crypto/encryption/target/

View file

@ -5,6 +5,65 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.12.3] - 2026-10-16
### Added
- 🔐 **End-to-end encrypted chats.** Direct messages and private group channels can now hold a temporary end-to-end encrypted chat: a member picks Encrypted Chat in the channel menu, the other members see "Encrypted chat in progress" with a Join link, messages of up to 16 KiB are encrypted in the browser with MLS and only passed through the server without being saved, participants can verify each other by comparing codes and see when others are typing, and the chat is cleared when you leave or reload and ends when channel membership or access changes. Administrators turn it on under Admin Settings > General > Encrypted channels or with "ENABLE_ENCRYPTED_CHANNELS" and grant it with the new Encrypted Channels permission, off by default or set with "USER_PERMISSIONS_FEATURES_ENCRYPTED_CHANNELS", and it needs websocket connections with websocket server logging off and HTTPS in production. [Commit](https://github.com/open-webui/open-webui/commit/7dc91de86c41866f4170602f074c39dbf1609377), [Commit](https://github.com/open-webui/open-webui/commit/0b3812f2857b4c65fd1ab931f40de4f1264edc46), [Commit](https://github.com/open-webui/open-webui/commit/b1e876896caee5bf30a010fb414a9249ec7f59a2), [Commit](https://github.com/open-webui/open-webui/commit/4dedaaf6e2292c2c4fb48668eee1292cfd7de2d0)
- 📡 **Much lighter streaming with Redis.** With Redis, a streaming reply is now stored under its own key and new text is appended to it instead of rewriting the whole reply into a shared hash on every update, and replies started on older instances are still read during an upgrade; with room channels on, each instance now only subscribes to the rooms of people connected to it, so Redis no longer sends it live updates for chats it does not serve. [Commit](https://github.com/open-webui/open-webui/commit/f5a26f53a089eeb9683ead687a1fd3e9125132a6) [Commit](https://github.com/open-webui/open-webui/commit/362b01d0b2ba677858a5af85caf3dd8544c5e6df) [#32478](https://github.com/open-webui/open-webui/issues/32478)
- 🔍 **Compare view options and undo.** The Compare revision view has a Comparison options menu to ignore whitespace, ignore emphasis markers or only show changes with the unchanged lines folded away, an Undo button that steps back through your edits in both panes in order, and changed lines that are now lined up by their matching text. [Commit](https://github.com/open-webui/open-webui/commit/d0750f6617acd3b57ce58ba1d543c37e6c793c8d), [Commit](https://github.com/open-webui/open-webui/commit/a087aa1deb4d999b122f882335fd08af8f06e72c), [Commit](https://github.com/open-webui/open-webui/commit/10ce0d23651fc2c96fd0d276147cd48105e21b84), [Commit](https://github.com/open-webui/open-webui/commit/3288fe010a5e8f8a59e1e5d2262957d175e56bde)
- 📝 **Richer Markdown file previews.** Markdown files opened in the terminal file browser now render with the same renderer as chat replies, including math, footnotes, highlighted code blocks, alerts and Mermaid diagrams, as well as HTML blocks mixed with Markdown, laid out as a compact document, and task list checkboxes in an editable file can be ticked to save the change to the file. [Commit](https://github.com/open-webui/open-webui/commit/c9a52208da9e8deeac6f2df7fcacbe519c2acb29) [Commit](https://github.com/open-webui/open-webui/commit/7c3980338b72c5faa7e7f3a088deb477644fef40) [Commit](https://github.com/open-webui/open-webui/commit/15ac5163ea00a934ec0c68d2bb7f6bc5c655223e) [#32514](https://github.com/open-webui/open-webui/issues/32514)
- 👀 **Live collaborators in shared chats.** The top bar of a chat now shows the pictures of the other people who have the same chat open at that moment, up to three and a count for the rest, with a Collaborators list that opens their profile cards. [Commit](https://github.com/open-webui/open-webui/commit/4dedaaf6e2292c2c4fb48668eee1292cfd7de2d0), [Commit](https://github.com/open-webui/open-webui/commit/8191ac30dd79b4a39130fe83ffc16035776be484), [Commit](https://github.com/open-webui/open-webui/commit/8e9519240fcdcd568a9b951ba06e523c58e68350)
- ✏️ **Edit and regenerate in shared chats with replies.** In a chat from a shared folder that allows replies, people who may edit messages can now edit a prompt to start a new branch and regenerate any reply, not only replies to their own messages, and others viewing the chat stay on the branch they had selected. [Commit](https://github.com/open-webui/open-webui/commit/2a6e0da057d3b6eb6ba506346c4db788cab735f0), [Commit](https://github.com/open-webui/open-webui/commit/788a61180be76aef27761b051300d6eefa715947)
- 🗄️ **Folder access in the folder editor.** Sharing a folder now happens through an Access button in the Edit Folder dialog instead of a Share item in the folder menu, the sharing mode can be set before anyone is added, and subfolders note that access granted to parent folders also applies to them. [Commit](https://github.com/open-webui/open-webui/commit/f4557ca06b8331c8914bac9e8f54ce63b0445c21), [Commit](https://github.com/open-webui/open-webui/commit/4a70253bc8586ad1d2a97241665b2060324bab37)
- 🔎 **Workspace search.** The search window now searches your chats, chats in folders shared with you, chats shared with you, channel and direct messages and notes in one place, with filters for content type, a folder or channel, and whether something was created by you or shared with you, highlighted matches, a preview of each result with Ctrl/Cmd + Enter, Copy link, and channel results that open the thread scrolled to the matching message. [Commit](https://github.com/open-webui/open-webui/commit/b4db56ae96651d926adb4aefc6ec1275f35c6b6f), [Commit](https://github.com/open-webui/open-webui/commit/ef28e99e587a2845a2852bb6ab8c182dc1588980), [Commit](https://github.com/open-webui/open-webui/commit/1a1a81d8e09330900ac74e4bc97a48e1e0d52a8a)
- 💭 **Comments in document previews.** Word, Excel, PowerPoint and PDF files opened in the terminal file browser now show their comments and replies, marked where they belong in the document and opened by hovering or tapping a marker, and older .xls spreadsheets can now be previewed too. [Commit](https://github.com/open-webui/open-webui/commit/ba0b2169e1799e1915c6dbb071c56d54896467fb)
- 🧹 **Clear Input Selections shortcut.** Clearing the model override, selected tools and filters and the web search, image generation and code interpreter toggles from the chat input is now a shortcut you can change in Settings > Shortcuts, still Escape by default. [Commit](https://github.com/open-webui/open-webui/commit/9cf48f4fb0a8aef30010294912a3a5674850beaf)
- 🙋 **Ask about a file from the file browser.** Selecting text in a file open in the terminal file browser now shows an "Ask or edit…" box that sends your request to the model with the file attached and the selection quoted, so it can answer or edit that file in the same terminal; unsaved edits can be saved before sending, and requests sent while a reply is running wait in the queue. [Commit](https://github.com/open-webui/open-webui/commit/af34515131d9efd41cfeae5c1ea50a7f7ed84443)
- ☁️ **Google Drive access limited to picked files.** The Google Drive picker now asks only for access to the files a user picks instead of read access to their whole Drive, so self-hosted installs no longer need Google's restricted-scope verification and users no longer see the "Google hasn't verified this app" warning. [#32531](https://github.com/open-webui/open-webui/pull/32531) [Commit](https://github.com/open-webui/open-webui/commit/ed5c2d7522c126b6163aad56762790025b31ec0b) [#29537](https://github.com/open-webui/open-webui/issues/29537)
- 📜 **Full text of long status lines.** Status lines above a reply, such as a token usage summary sent by a function, can now be clicked to show their full text, and with several status lines the history shows every entry in full. [#32532](https://github.com/open-webui/open-webui/pull/32532) [Commit](https://github.com/open-webui/open-webui/commit/291b57bec24ec670050b996ede45ea6dd56c7f9f) [#30056](https://github.com/open-webui/open-webui/issues/30056)
- 📏 **Skill read limits.** Administrators can set how many characters of a skill the model reads at once and how long the preview of longer instructions is, under Admin Settings > General with "Skill Read Limit" and "Skill Preview Length" or the "SKILL_CONTENT_MAX_CHARS" and "SKILL_PREVIEW_MAX_CHARS" environment variables, which default to 20000 and 5000. [Commit](https://github.com/open-webui/open-webui/commit/92e64c1d59cdd99b5f33a108186a1d368daab964)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Portuguese (Brazil), Chinese (Traditional), Arabic, Bengali, Hebrew, Georgian, Punjabi, Slovak, Serbian, Urdu, Uzbek and Vietnamese were enhanced and expanded. [#32440](https://github.com/open-webui/open-webui/pull/32440), [#32482](https://github.com/open-webui/open-webui/pull/32482), [#32501](https://github.com/open-webui/open-webui/pull/32501)
### Fixed
- 🧾 **API key header requests in the audit log.** Requests authenticated with an API key in the x-api-key header, as Anthropic SDKs send it, or in a header set with "CUSTOM_API_KEY_HEADER" now appear in the audit log under the key's owner, and failed attempts with a wrong key are logged without a user, where these requests were missing from the audit log. [#32443](https://github.com/open-webui/open-webui/pull/32443), [Commit](https://github.com/open-webui/open-webui/commit/f7c8ad012ddcf458c39cdf8a96b41c410655196c)
- 📚 **Knowledge sharing kept on rename.** A collaborator with write access who edits a shared knowledge base's name or description no longer turns off sharing they could not set themselves, such as Public. [#32450](https://github.com/open-webui/open-webui/pull/32450), [Commit](https://github.com/open-webui/open-webui/commit/08f6023427cdad6179b8fc5f656c99f3f0ad44f8), [#32449](https://github.com/open-webui/open-webui/issues/32449)
- 🤝 **Prompt and tool sharing kept on save.** A collaborator with write access who saves a shared prompt or tool no longer makes it private for everyone; sharing they could not set themselves is now kept. [#32493](https://github.com/open-webui/open-webui/pull/32493), [Commit](https://github.com/open-webui/open-webui/commit/5dff6489d2f6a6c33c49ca035e5384ff5dd29006), [#32492](https://github.com/open-webui/open-webui/issues/32492)
- 📄 **PDF export of long notes and chats.** Long notes and chats now download as PDF, where Firefox failed with "Canvas exceeds max size" and Chrome saved a black PDF. [#32459](https://github.com/open-webui/open-webui/pull/32459), [Commit](https://github.com/open-webui/open-webui/commit/f78e46ef44ecc1a7ea8a4951edc17e22bf1d5cad), [#18334](https://github.com/open-webui/open-webui/issues/18334), [#26373](https://github.com/open-webui/open-webui/issues/26373), [#19583](https://github.com/open-webui/open-webui/discussions/19583)
- ⏩ **Continue Response with several models.** Continue Response now continues the reply it was clicked under, where in a chat with several models it could extend another model's reply. [#32456](https://github.com/open-webui/open-webui/pull/32456), [Commit](https://github.com/open-webui/open-webui/commit/3c82fda4e813e00d33ca16a8ddd61596296a2066), [#32455](https://github.com/open-webui/open-webui/issues/32455)
- 🏷️ **Tags of imported chats.** Imported chats now show their tags and the tags appear in tag suggestions, where tags missing from the account were not created on import. [#32454](https://github.com/open-webui/open-webui/pull/32454), [Commit](https://github.com/open-webui/open-webui/commit/fd47370e851c3fcb55ba69aec39488aaec7f4b02), [#32453](https://github.com/open-webui/open-webui/issues/32453)
- 🔖 **Tags of archived chats.** Searching the sidebar for a tag whose chats are all archived no longer deletes the tag, which lost its original name when the chats were unarchived. [#32452](https://github.com/open-webui/open-webui/pull/32452), [Commit](https://github.com/open-webui/open-webui/commit/58b821a4642480b2202320b45146bace72962f3b), [#32451](https://github.com/open-webui/open-webui/issues/32451)
- ⌨️ **Input methods in channels.** Pressing Enter to confirm a word while typing with an input method, such as for Japanese, Chinese or Korean, no longer sends and clears a channel message before the word is in. [Commit](https://github.com/open-webui/open-webui/commit/9d0aa791ad70ef2a7771c74955e2c9304021609e)
- 🖱️ **Scroll error after leaving a chat.** Scrolling a chat right before opening Notes no longer logs a TypeError in the browser console. [#32458](https://github.com/open-webui/open-webui/pull/32458), [Commit](https://github.com/open-webui/open-webui/commit/9245e31ccba8e464054297b1f3660ab3e7f77969), [#32457](https://github.com/open-webui/open-webui/issues/32457)
- 📐 **Steady first turn in new chats.** The latest message and reply in a new chat now keep their real height when the chat gets saved and the message list reloads, so the view no longer jumps on the first turn. [Commit](https://github.com/open-webui/open-webui/commit/bbccff89c1cf318c21cd9eeb4228f98882a33736)
- 💬 **Sending in channels.** Your own channel message now stays in place when the server confirms it instead of being redrawn, and the channel scrolls to your new message as you send it, where it could jump or keep the old scroll position. [Commit](https://github.com/open-webui/open-webui/commit/99457b373e6b81fe50df0d18a75d126706237841)
- 📂 **Terminal folder in new chats.** In a new chat, files you attach are uploaded to the folder open in the terminal file browser and the file browser stays in that folder once the chat is created, where uploads could go to the default folder and the browser jumped back to it. [Commit](https://github.com/open-webui/open-webui/commit/7ca62fdf93f6177e929cca20c712f3d958e1d935)
- 📁 **Shared folder settings and access.** Editing a shared folder's models, system prompt or files no longer resets its sharing mode, and a subfolder in a folder shared with write access stays writable when it is also shared directly with read access. [Commit](https://github.com/open-webui/open-webui/commit/f4557ca06b8331c8914bac9e8f54ce63b0445c21), [Commit](https://github.com/open-webui/open-webui/commit/4a70253bc8586ad1d2a97241665b2060324bab37)
- 🔑 **OAuth token refresh with client_secret_basic.** When "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD" is client_secret_basic, token refresh now sends the client credentials in the Basic header like sign-in does, where providers that accept only Basic rejected the refresh and Open WebUI deleted the user's OAuth tokens, breaking model connections and tool servers that use them until the next sign-in. [#32507](https://github.com/open-webui/open-webui/pull/32507), [Commit](https://github.com/open-webui/open-webui/commit/ae6793dd4f209455387bcc418a5c775eec0b16c3), [#32506](https://github.com/open-webui/open-webui/issues/32506)
- 🛠️ **OAuth tools after sign-in.** An OAuth MCP tool now stays turned on after its sign-in redirect when the model has default tools, and links with ?tools= turn the listed tools on. [#32488](https://github.com/open-webui/open-webui/pull/32488), [Commit](https://github.com/open-webui/open-webui/commit/81722918384cb56608a2ab26ca03059b3a811758), [#32476](https://github.com/open-webui/open-webui/issues/32476)
- 🔤 **Admin default font kept.** Saving Settings > Interface no longer stores the administrator's default font and image compression size as your own settings, so later changes to those defaults still reach you. [#32497](https://github.com/open-webui/open-webui/pull/32497), [Commit](https://github.com/open-webui/open-webui/commit/e979a1f9e2c86d60c7ee3ba48fb0dc42a3b65aa0), [#32496](https://github.com/open-webui/open-webui/issues/32496)
- 🔊 **Read aloud with slow speech.** Read aloud no longer stops after the first sentence when the speech engine takes longer to produce the next one than the current one takes to play. [#32484](https://github.com/open-webui/open-webui/pull/32484), [Commit](https://github.com/open-webui/open-webui/commit/7da8653e71cda807609d9ec64912cd319c0d6ccf), [#32446](https://github.com/open-webui/open-webui/issues/32446)
- 🖼️ **Inline HTML previews on Per chat terminals.** Inline HTML previews in replies now find the file in the chat's own terminal workspace when the Open Terminal chat context is Per chat, where they showed "File not found". [#32485](https://github.com/open-webui/open-webui/pull/32485), [Commit](https://github.com/open-webui/open-webui/commit/ec39df7da56f6947fde17a9be2c146251f891b26), [#32477](https://github.com/open-webui/open-webui/issues/32477)
- 🧵 **Replies in channel threads.** A reply started in one channel thread no longer carries over to the next thread you open, where messages could quote a message from the other thread. [#32490](https://github.com/open-webui/open-webui/pull/32490), [Commit](https://github.com/open-webui/open-webui/commit/0696bfdf276fe6331c6dfaf50c53676d08b1574a), [#32489](https://github.com/open-webui/open-webui/issues/32489)
- 📑 **Scanned PDF previews.** Previewing a scanned PDF without a text layer in the knowledge base viewer no longer scrolls the page up and pushes the header and toolbar out of reach. [#32487](https://github.com/open-webui/open-webui/pull/32487), [Commit](https://github.com/open-webui/open-webui/commit/1bc5648a7b67c82405f86ed4bd0506f9ae3eb739), [#32481](https://github.com/open-webui/open-webui/issues/32481)
- ↩️ **Right-to-left lists.** Bullets, numbers and task checkboxes of Markdown lists now sit on the correct side for right-to-left text in replies and file previews. [Commit](https://github.com/open-webui/open-webui/commit/6768f52aca633945292da08ff9450fd4dc62705f), [#32444](https://github.com/open-webui/open-webui/issues/32444)
- 🔁 **Regenerate in cloned shared chats.** Regenerate now works in a chat you cloned or forked from someone else's share, where it failed with "You can only regenerate your own messages". [#32508](https://github.com/open-webui/open-webui/pull/32508), [Commit](https://github.com/open-webui/open-webui/commit/ad3e5a6c425f7b4af8a66f2e8dc42a7625526f95), [#32505](https://github.com/open-webui/open-webui/issues/32505)
- 🗑️ **Delete hidden for repliers in shared chats.** People replying in a shared chat no longer see a Delete button the server refuses, which made a deleted reply come back on reload; Delete shows to the chat's owner and to administrators when "ENABLE_ADMIN_CHAT_ACCESS" is on. [#32480](https://github.com/open-webui/open-webui/pull/32480), [Commit](https://github.com/open-webui/open-webui/commit/493b10630b0c19ee5a60f9de0fc5d997bb82bbb1), [#32479](https://github.com/open-webui/open-webui/issues/32479)
- 🎛️ **Model default features can be turned off.** A model can now switch off a Default Feature, such as Code Interpreter, that the administrator turned on in Model Defaults, where it came back on when reopened and in new chats. [#32500](https://github.com/open-webui/open-webui/pull/32500), [Commit](https://github.com/open-webui/open-webui/commit/a8dd8dbf987a3c7f4d6aa7c102e4e9cb158c4f5a), [#32498](https://github.com/open-webui/open-webui/issues/32498)
- 🧰 **Built-in tools turned back on stay on.** A built-in tool such as Memory that is off in Model Defaults now stays on after a model turns it back on and is saved. [#32504](https://github.com/open-webui/open-webui/pull/32504), [Commit](https://github.com/open-webui/open-webui/commit/9cc648cad8589686e617e4a64bea10134ff16288), [#32503](https://github.com/open-webui/open-webui/issues/32503)
- 🏠 **Refresh with Landing Page Mode set to Chat.** Refreshing a new chat when Landing Page Mode is Chat no longer hides the message field and opens the model menu in the top left corner. [#32510](https://github.com/open-webui/open-webui/pull/32510), [Commit](https://github.com/open-webui/open-webui/commit/5d4f16bcc9af1afac55dc0ffb4dced96041531a5), [#32509](https://github.com/open-webui/open-webui/issues/32509)
- 🗜️ **Compaction during tool runs keeps sources out.** Context compaction during a long series of tool calls now summarizes your own message without the knowledge and file content added to it, and that content is given back to the model afterwards. [Commit](https://github.com/open-webui/open-webui/commit/597b00a5479ac0466b2f4d23a5e902ee0950091d), [#32491](https://github.com/open-webui/open-webui/issues/32491)
- 🧩 **Built-in tools with a custom tools parameter.** A model with a custom parameter named tools, such as one that turns on a provider's native web search, now keeps its built-in tools, attached tools and file context, and its custom tools are sent alongside them. [#32530](https://github.com/open-webui/open-webui/pull/32530) [Commit](https://github.com/open-webui/open-webui/commit/3ccea416b4e69ef688acdd879c8cb7cd31d5ab84) [#32528](https://github.com/open-webui/open-webui/issues/32528)
- 🔌 **Tool servers back after an outage.** With Redis, tools from OpenAPI tool servers now come back once the servers are reachable again, where an empty list cached while every server was down kept them missing until a restart or a save in the tool server settings. [#32533](https://github.com/open-webui/open-webui/pull/32533) [Commit](https://github.com/open-webui/open-webui/commit/51a4d582706311502ae53a6c11876b87a49f5400) [#29573](https://github.com/open-webui/open-webui/issues/29573)
- 📥 **PDF links read as documents.** PDFs from sites that block unknown clients, such as Wikimedia, and arXiv links like arxiv.org/pdf/1706.03762 now go to document extraction, where the model got the raw PDF bytes. [#32523](https://github.com/open-webui/open-webui/pull/32523) [Commit](https://github.com/open-webui/open-webui/commit/a5942da0a264b4e97a5d6a18adea7d4fcbfc275a) [#32524](https://github.com/open-webui/open-webui/pull/32524) [Commit](https://github.com/open-webui/open-webui/commit/823670d9fa925255d3ebf28e55779b8be45a1c3a)
### Changed
- 🎯 **Model skills now load their full instructions.** With the Skills builtin tool on, skills attached to a model in the model editor, which every new chat pre-selects, and skills picked from the chat input menu now send their full instructions with each message, where they were only listed by name and description for the model to load with view_skill; deployments that attached skills to models only to make them available should remove them from the model to avoid the extra prompt length, since every skill the user can read is listed for the model anyway. [Commit](https://github.com/open-webui/open-webui/commit/5cf7c03332ad3fd68ddba276b94a87f8cea25c7a)
- 📮 **Redis permissions for room channels.** With "WEBSOCKET_REDIS_ROOM_CHANNELS" on, the Redis user now needs the PUBLISH, SUBSCRIBE, UNSUBSCRIBE and PING permissions and access to the "&socketio" and "&socketio#\*" channels, while PSUBSCRIBE is no longer used; a missing permission is logged with how to fix it. [Commit](https://github.com/open-webui/open-webui/commit/362b01d0b2ba677858a5af85caf3dd8544c5e6df)
## [0.12.2] - 2026-10-14
### Added

View file

@ -1949,6 +1949,10 @@ USER_PERMISSIONS_FEATURES_NOTES = os.getenv('USER_PERMISSIONS_FEATURES_NOTES', '
USER_PERMISSIONS_FEATURES_CHANNELS = os.getenv('USER_PERMISSIONS_FEATURES_CHANNELS', 'True').lower() == 'true'
USER_PERMISSIONS_FEATURES_ENCRYPTED_CHANNELS = (
os.getenv('USER_PERMISSIONS_FEATURES_ENCRYPTED_CHANNELS', 'False').lower() == 'true'
)
USER_PERMISSIONS_FEATURES_API_KEYS = os.getenv('USER_PERMISSIONS_FEATURES_API_KEYS', 'False').lower() == 'true'
USER_PERMISSIONS_FEATURES_MEMORIES = os.getenv('USER_PERMISSIONS_FEATURES_MEMORIES', 'True').lower() == 'true'
@ -2032,6 +2036,7 @@ DEFAULT_USER_PERMISSIONS = {
'notes': USER_PERMISSIONS_FEATURES_NOTES,
'folders': USER_PERMISSIONS_FEATURES_FOLDERS,
'channels': USER_PERMISSIONS_FEATURES_CHANNELS,
'encrypted_channels': USER_PERMISSIONS_FEATURES_ENCRYPTED_CHANNELS,
'direct_tool_servers': USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS,
# Chat features
'web_search': USER_PERMISSIONS_FEATURES_WEB_SEARCH,
@ -2053,8 +2058,18 @@ ENABLE_FOLDERS = os.getenv('ENABLE_FOLDERS', 'True').lower() == 'true'
FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '')
SKILL_CONTENT_MAX_CHARS = int(os.environ['SKILL_CONTENT_MAX_CHARS']) if os.getenv('SKILL_CONTENT_MAX_CHARS') else None
if SKILL_CONTENT_MAX_CHARS is not None and SKILL_CONTENT_MAX_CHARS <= 0:
raise ValueError('SKILL_CONTENT_MAX_CHARS must be a positive integer or unset')
SKILL_PREVIEW_MAX_CHARS = int(os.environ['SKILL_PREVIEW_MAX_CHARS']) if os.getenv('SKILL_PREVIEW_MAX_CHARS') else None
if SKILL_PREVIEW_MAX_CHARS is not None and SKILL_PREVIEW_MAX_CHARS <= 0:
raise ValueError('SKILL_PREVIEW_MAX_CHARS must be a positive integer or unset')
ENABLE_CHANNELS = os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true'
ENABLE_ENCRYPTED_CHANNELS = os.getenv('ENABLE_ENCRYPTED_CHANNELS', 'False').lower() == 'true'
ENABLE_APPS = os.getenv('ENABLE_APPS', 'False').lower() == 'true'
CHANNEL_MODEL_RESPONSE_MODE = os.getenv('CHANNEL_MODEL_RESPONSE_MODE', 'thread')
@ -2885,6 +2900,8 @@ ENABLE_LDAP_GROUP_CREATION = os.getenv('ENABLE_LDAP_GROUP_CREATION', 'False').lo
LDAP_ATTRIBUTE_FOR_GROUPS = os.getenv('LDAP_ATTRIBUTE_FOR_GROUPS', 'memberOf')
DEFAULT_CONFIG = {
'skills.content_max_chars': SKILL_CONTENT_MAX_CHARS,
'skills.preview_max_chars': SKILL_PREVIEW_MAX_CHARS,
'direct.enable': ENABLE_DIRECT_CONNECTIONS,
'direct.integrations.enable': ENABLE_DIRECT_INTEGRATIONS,
'ollama.enable': ENABLE_OLLAMA_API,
@ -3179,6 +3196,7 @@ DEFAULT_CONFIG = {
'folders.enable': ENABLE_FOLDERS,
'folders.max_file_count': FOLDER_MAX_FILE_COUNT,
'channels.enable': ENABLE_CHANNELS,
'channels.encryption.enable': ENABLE_ENCRYPTED_CHANNELS,
'apps.enable': ENABLE_APPS,
'channels.model_response_mode': CHANNEL_MODEL_RESPONSE_MODE,
'calendar.enable': ENABLE_CALENDAR,

View file

@ -177,6 +177,7 @@ from open_webui.routers import (
prompts,
retrieval,
scim,
search,
skills,
tasks,
terminals,
@ -894,6 +895,7 @@ app.include_router(apps.router, prefix='/api/v1/apps', tags=['apps'])
app.include_router(channels.router, prefix='/api/v1/channels', tags=['channels'])
app.include_router(chats.router, prefix='/api/v1/chats', tags=['chats'])
app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
app.include_router(search.router, prefix='/api/v1/search', tags=['search'])
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
@ -2338,6 +2340,7 @@ async def get_app_config(request: Request):
'folders.enable',
'folders.max_file_count',
'channels.enable',
'channels.encryption.enable',
'apps.enable',
'calendar.enable',
'automations.enable',
@ -2434,6 +2437,7 @@ async def get_app_config(request: Request):
'enable_folders': config.get('folders.enable'),
'folder_max_file_count': config.get('folders.max_file_count'),
'enable_channels': config.get('channels.enable'),
'enable_encrypted_channels': config.get('channels.encryption.enable'),
'enable_apps': config.get('apps.enable'),
'enable_calendar': config.get('calendar.enable'),
'enable_automations': config.get('automations.enable'),

View file

@ -1270,10 +1270,8 @@ class ChatTable:
parent_id = None
if user_message:
existing = messages.get(user_message_id)
if existing and (
existing.get('role') != 'user' or (existing.get('user_id') or chat.user_id) != user.id
):
raise HTTPException(403, 'You can only regenerate your own messages.')
if existing and existing.get('role') != 'user':
raise HTTPException(403, 'You can only regenerate replies to user messages.')
parent_id = existing.get('parentId') if existing else user_message.get('parentId')
if parent_id is not None and parent_id not in messages:
raise HTTPException(409, 'Parent message no longer exists.')
@ -1292,7 +1290,7 @@ class ChatTable:
user_id=user.id,
user={'id': user.id, 'name': user.name},
)
elif 'models' in user_message:
elif 'models' in user_message and (existing.get('user_id') or chat.user_id) == user.id:
message = {**existing, 'models': user_message['models']}
turn[user_message_id] = self.upsert_message_to_history(
history, user_message_id, self._clean_null_bytes(message)
@ -1320,7 +1318,7 @@ class ChatTable:
chat.current_message_id = history['currentId']
chat.updated_at = int(time.time())
for mid, message in turn.items():
await ChatMessages.upsert_message(mid, chat_id, user.id, message, db=db)
await ChatMessages.upsert_message(mid, chat_id, message.get('user_id') or chat.user_id, message, db=db)
if parent_id:
turn[parent_id] = history['messages'][parent_id]
return {'messages': turn, 'currentId': history['currentId']}

View file

@ -408,6 +408,9 @@ class MessageTable:
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
*,
around: str | None = None,
before: str | None = None,
) -> list[MessageReplyToResponse]:
async with get_async_db_context(db) as db:
message = await db.get(Message, parent_id)
@ -416,12 +419,40 @@ class MessageTable:
if not message or message.channel_id != channel_id:
return []
stmt = select(Message).filter_by(channel_id=channel_id, parent_id=parent_id)
if around or before:
anchor = await db.get(Message, before or around)
if (
not anchor
or anchor.channel_id != channel_id
or (anchor.id != parent_id and anchor.parent_id != parent_id)
):
return []
if before:
stmt = stmt.where(
or_(
Message.created_at < anchor.created_at,
and_(Message.created_at == anchor.created_at, Message.id < anchor.id),
)
)
skip = 0
else:
# A bounded window starts just above the match, even in very old threads.
newer = (
select(func.count())
.select_from(Message)
.where(
Message.channel_id == channel_id,
Message.parent_id == parent_id,
or_(
Message.created_at > anchor.created_at,
and_(Message.created_at == anchor.created_at, Message.id > anchor.id),
),
)
)
skip = max(0, (await db.scalar(newer)) - 10)
result = await db.execute(
select(Message)
.filter_by(channel_id=channel_id, parent_id=parent_id)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
stmt.order_by(Message.created_at.desc(), Message.id.desc()).offset(skip).limit(limit)
)
all_messages = list(result.scalars().all())

View file

@ -120,6 +120,9 @@ def _extract_text_from_binary_response(
if not filename or '.' not in filename:
ext = mimetypes.guess_extension(content_type) or ''
filename = f'download{ext}'
elif not mimetypes.guess_type(filename)[0] and content_type != 'application/octet-stream':
# The dot in names like arXiv's 1706.03762 is not an extension.
filename += mimetypes.guess_extension(content_type) or ''
suffix = '.' + filename.split('.')[-1].lower() if '.' in filename else ''
@ -236,7 +239,7 @@ async def get_content_from_url(request, url: str, *, config: dict | None = None)
def _get_content_from_url_sync(request, url: str, config: dict):
from open_webui.retrieval.web.utils import validate_url, get_ssrf_safe_requests_session
from open_webui.retrieval.web.utils import WEB_LOADER_HEADERS, validate_url, get_ssrf_safe_requests_session
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
validate_url(url)
@ -261,6 +264,9 @@ def _get_content_from_url_sync(request, url: str, config: dict):
try:
# Probe through the connect-time SSRF guard; bare requests.get re-resolves (DNS-rebinding gap).
session = get_ssrf_safe_requests_session()
# Match the web loader's request, or sites that block unknown clients make a file look like a web page.
session.headers = WEB_LOADER_HEADERS
session.verify = config['web.loader.ssl_verification']
response = session.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')

View file

@ -1031,6 +1031,17 @@ class SafePlaywrightURLLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
raise e
WEB_LOADER_HEADERS = {
'User-Agent': USER_AGENT or 'DefaultLangchainUserAgent',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://www.google.com/',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
class SafeWebBaseLoader(BaseLoader):
"""Fetch pages with connect-time address checks and bounded concurrency."""
@ -1059,15 +1070,7 @@ class SafeWebBaseLoader(BaseLoader):
# Preserve the synchronous loader's environment-proxy behavior.
self.session = get_ssrf_safe_requests_session()
self.session.verify = verify_ssl
self.session.headers = {
'User-Agent': USER_AGENT or 'DefaultLangchainUserAgent',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://www.google.com/',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
self.session.headers = WEB_LOADER_HEADERS
async def fetch_all(self, urls):
semaphore = asyncio.Semaphore(self.requests_per_second)

View file

@ -82,7 +82,7 @@ from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.mfa import MFA_CONFIG_KEYS, MfaConfigForm, get_mfa_config, limit_account, update_mfa_config
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.rate_limit import RateLimiter
from pydantic import BaseModel, StrictStr, field_validator
from pydantic import BaseModel, Field, StrictStr, field_validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -128,11 +128,14 @@ ADMIN_CONFIG_KEYS = {
'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
'ENABLE_AUTOMATIONS': 'automations.enable',
'ENABLE_CHANNELS': 'channels.enable',
'ENABLE_ENCRYPTED_CHANNELS': 'channels.encryption.enable',
'ENABLE_APPS': 'apps.enable',
'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode',
'ENABLE_CALENDAR': 'calendar.enable',
'ENABLE_MEMORIES': 'memories.enable',
'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable',
'SKILL_CONTENT_MAX_CHARS': 'skills.content_max_chars',
'SKILL_PREVIEW_MAX_CHARS': 'skills.preview_max_chars',
'ENABLE_NOTES': 'notes.enable',
'ENABLE_USER_WEBHOOKS': 'ui.enable_user_webhooks',
'ENABLE_USER_STATUS': 'users.enable_status',
@ -1178,11 +1181,14 @@ class AdminConfig(MfaConfigForm):
AUTOMATION_MIN_INTERVAL: int | str | None = None
ENABLE_AUTOMATIONS: bool
ENABLE_CHANNELS: bool
ENABLE_ENCRYPTED_CHANNELS: bool = False
ENABLE_APPS: bool = False
CHANNEL_MODEL_RESPONSE_MODE: str = 'thread'
ENABLE_CALENDAR: bool
ENABLE_MEMORIES: bool
ENABLE_MEMORY_SYSTEM_CONTEXT: bool
SKILL_CONTENT_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True)
SKILL_PREVIEW_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True)
ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool
ENABLE_USER_STATUS: bool
@ -1225,10 +1231,16 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
updates.pop(key, None)
if 'ENABLE_LOGIN_FORM' not in form_data.model_fields_set:
updates.pop('ui.enable_login_form', None)
if 'ENABLE_ENCRYPTED_CHANNELS' not in form_data.model_fields_set:
updates.pop('channels.encryption.enable', None)
if 'ENABLE_APPS' not in form_data.model_fields_set:
updates.pop('apps.enable', None)
if 'I18N' not in form_data.model_fields_set:
updates.pop('ui.i18n', None)
if 'SKILL_CONTENT_MAX_CHARS' not in form_data.model_fields_set:
updates.pop('skills.content_max_chars', None)
if 'SKILL_PREVIEW_MAX_CHARS' not in form_data.model_fields_set:
updates.pop('skills.preview_max_chars', None)
updates['ui.default_interface_settings'] = form_data.DEFAULT_INTERFACE_SETTINGS or {}
updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''

View file

@ -42,6 +42,7 @@ from open_webui.models.users import (
Users,
)
from open_webui.socket.main import (
encryption,
emit_to_users,
enter_room_for_users,
get_user_ids_from_room,
@ -657,6 +658,7 @@ async def update_is_active_member_by_id_and_user_id(
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
await encryption.invalidate(id)
await Channels.update_member_active_status(channel.id, user.id, form_data.is_active, db=db)
await publish_event(
request,
@ -753,6 +755,7 @@ async def remove_members_by_id(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
await encryption.invalidate(id)
deleted = await Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db)
if channel.type == 'group':
await leave_room_for_users(f'channel:{channel.id}', form_data.user_ids)
@ -803,6 +806,7 @@ async def update_channel_by_id(
previous_access_grants = channel.access_grants
try:
await encryption.invalidate(id)
channel = await Channels.update_channel_by_id(id, form_data, db=db)
# Group and DM channels use membership instead of access grants.
if form_data.access_grants is not None and channel.type not in ['group', 'dm']:
@ -847,6 +851,7 @@ async def delete_channel_by_id(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
await encryption.invalidate(id)
await Channels.delete_channel_by_id(id, db=db)
await sio.close_room(f'channel:{id}')
await publish_event(
@ -1680,6 +1685,8 @@ async def get_channel_thread_messages(
message_id: str,
skip: int = 0,
limit: int = 50,
around: str | None = None,
before: str | None = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -1695,7 +1702,9 @@ async def get_channel_thread_messages(
if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
message_list = await Messages.get_messages_by_parent_id(id, message_id, skip, limit, db=db)
message_list = await Messages.get_messages_by_parent_id(
id, message_id, skip, min(max(limit, 1), 100), db=db, around=around, before=before
)
if not message_list:
return []

View file

@ -864,6 +864,9 @@ async def import_chats(
try:
chats = await Chats.import_chats(user.id, form_data.chats, db=db)
await Tags.ensure_tags_exist(
list({tag_id for chat in chats for tag_id in (chat.meta or {}).get('tags', [])}), user.id, db=db
)
await publish_event(
request,
EVENTS.CHAT_IMPORTED,
@ -957,7 +960,7 @@ async def search_user_chats(
words = text.strip().split(' ')
if page == 1 and len(words) == 1 and words[0].startswith('tag:'):
tag_id = words[0].replace('tag:', '')
if len(chat_list) == 0:
if len(chat_list) == 0 and await Chats.count_chats_by_tag_name_and_user_id(tag_id, user.id, db=db) == 0:
if await Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db):
log.debug('deleting tag: %s', tag_id)
await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
@ -1837,6 +1840,13 @@ async def fork_chat_by_id(
)
if not is_owner:
updated_chat = (await shared_chat_response(chat.model_copy(update={'chat': updated_chat}), user, db=db))['chat']
messages = list((updated_chat.get('history', {}).get('messages') or {}).values())
messages.extend(updated_chat.get('messages') or [])
for message in messages:
if (message.get('user_id') or chat.user_id) == chat.user_id:
message['user_id'] = user.id
if message.get('role') == 'user':
message['user'] = {'id': user.id, 'name': user.name}
meta = {
**((chat.meta or {}) if is_owner else {}),
'forked_from': chat.id,
@ -1979,6 +1989,13 @@ async def clone_shared_chat_by_id(
'branchPointMessageId': chat.chat['history']['currentId'],
'title': f'Clone of {chat.title}',
}
messages = list((updated_chat.get('history', {}).get('messages') or {}).values())
messages.extend(updated_chat.get('messages') or [])
for message in messages:
if (message.get('user_id') or chat.user_id) == chat.user_id:
message['user_id'] = user.id
if message.get('role') == 'user':
message['user'] = {'id': user.id, 'name': user.name}
chats = await Chats.import_chats(
user.id,

View file

@ -267,19 +267,22 @@ async def get_shared_folders(
]
# Also include child folders of shared folders (inheritance)
seen_ids = {folder.id for folder in shared_folders}
result_by_id = {folder['id']: folder for folder in results}
for folder in shared_folders:
children = await Folders.get_children_folders_by_id_and_user_id(folder.id, folder.user_id, db=db)
for child in children or []:
if child.id not in seen_ids:
seen_ids.add(child.id)
results.append(
{
**child.model_dump(),
'owner_name': owner_names.get(child.user_id, 'Unknown'),
'permission': 'write' if child.user_id == user.id else folder_perms[folder.id],
}
)
permission = 'write' if folder.user_id == user.id or child.user_id == user.id else folder_perms[folder.id]
if child.id in result_by_id:
# A weaker direct grant must not hide inherited write access.
if permission == 'write':
result_by_id[child.id]['permission'] = 'write'
else:
result_by_id[child.id] = {
**child.model_dump(),
'owner_name': owner_names.get(child.user_id, 'Unknown'),
'permission': permission,
}
results.append(result_by_id[child.id])
return results

View file

@ -4,7 +4,7 @@ from pathlib import Path
from typing import Optional, Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status, Query
from open_webui.config import CACHE_DIR
from open_webui.config import CACHE_DIR, DEFAULT_USER_PERMISSIONS
from open_webui.models.config import Config
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
@ -33,7 +33,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from pydantic import BaseModel
from open_webui.utils.access_control import combine_permissions
from open_webui.utils.access_control import combine_permissions, fill_missing_permissions
from open_webui.utils.json_codec import JSONCodec
log = logging.getLogger(__name__)
@ -418,7 +418,9 @@ async def preview_group_access(
active_models = [m for m in all_models if m.is_active]
ancestors = (await db.execute(select(Group).where(Group.id.in_(group_ids - {id})))).scalars().all()
inherited_permissions = JSONCodec.loads(JSONCodec.dumps(await Config.get('user.permissions') or {}))
inherited_permissions = fill_missing_permissions(
JSONCodec.loads(JSONCodec.dumps(await Config.get('user.permissions') or {})), DEFAULT_USER_PERMISSIONS
)
for ancestor in ancestors:
inherited_permissions = combine_permissions(inherited_permissions, ancestor.permissions or {})
effective_permissions = combine_permissions(

View file

@ -7,7 +7,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
@ -289,13 +289,28 @@ async def update_prompt_by_id(
detail=ERROR_MESSAGES.COMMAND_TAKEN,
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_prompts',
)
if form_data.access_grants is not None:
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in prompt.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_access_grants,
'sharing.public_prompts',
)
# Use the ID from the found prompt
updated_prompt = await Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db)

View file

@ -0,0 +1,442 @@
"""Permission-scoped workspace search. Shared snapshots never read the live chat."""
from typing import Literal
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Query
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrant, AccessGrants
from open_webui.models.channels import ChannelMember, Channels
from open_webui.models.chats import Chat
from open_webui.models.config import Config
from open_webui.models.folders import Folder
from open_webui.models.groups import Groups
from open_webui.models.messages import Message
from open_webui.models.notes import Note, PinnedNote
from open_webui.models.shared_chats import SharedChat
from open_webui.models.users import User
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from pydantic import BaseModel, Field
from sqlalchemy import BigInteger, and_, case, cast, func, literal, or_, select, text, union_all
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
PAGE_SIZE = 24
class SearchResult(BaseModel):
id: str
kind: Literal['chat', 'message', 'note']
title: str
context: str
snippet: str
preview: str
url: str
updated_at: int
shared: bool = False
pinned: bool = False
rank: int = Field(exclude=True, default=3)
class SearchScope(BaseModel):
id: str
name: str
kind: Literal['folder', 'channel']
class SearchResponse(BaseModel):
items: list[SearchResult]
has_more: bool
scopes: list[SearchScope]
kinds: list[str]
def terms_for(query: str) -> list[str]:
return list(dict.fromkeys(query.lower().split()))[:12]
def pattern(value: str) -> str:
return '%' + value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + '%'
def excerpt(content: str, query: str, length: int = 200) -> str:
content = content or ''
positions = [content.lower().find(term) for term in terms_for(query)]
position = min((p for p in positions if p >= 0), default=0)
start = max(0, position - length // 4)
return (
('…' if start else '')
+ content[start : start + length].strip()
+ ('…' if start + length < len(content) else '')
)
def chat_passage(data: dict, query: str) -> tuple[str, str | None]:
history = (data.get('history') or {}).get('messages') or {}
messages = list(history.values()) if history else data.get('messages') or []
messages = [m for m in messages if isinstance(m, dict) and isinstance(m.get('content'), str)]
terms = terms_for(query)
match = next((m for m in messages if terms and all(t in m['content'].lower() for t in terms)), None)
if match is None:
match = next((m for m in messages if any(t in m['content'].lower() for t in terms)), None)
fallback = messages[-1] if messages else {}
return (match or fallback).get('content', ''), match.get('id') if match else None
def title_rank(title, query: str):
if not query:
return literal(3)
return case((func.lower(title) == query.lower(), 0), (title.ilike(pattern(query), escape='\\'), 1), else_=3)
def message_match_sql(table: str, dialect: str, key: str):
# Identifiers are internal constants; all user input remains bound parameters.
if dialect == 'sqlite':
sources = [f"json_each({table}.chat, '$.history.messages')", f"json_each({table}.chat, '$.messages')"]
return text(
'('
+ ' OR '.join(
f"EXISTS (SELECT 1 FROM {source} AS m WHERE json_type(m.value, '$.content') = 'text' "
f"AND lower(m.value ->> 'content') LIKE :{key} ESCAPE '\\')"
for source in sources
)
+ ')'
)
sources = [f"json_each({table}.chat->'history'->'messages')", f"json_array_elements({table}.chat->'messages')"]
return text(
'('
+ ' OR '.join(
f"EXISTS (SELECT 1 FROM {source} AS m WHERE json_typeof(m.value->'content') = 'string' "
f"AND lower(m.value->>'content') LIKE :{key} ESCAPE '\\')"
for source in sources
)
+ ')'
)
async def search_workspace( # noqa: C901 — keep permission scoping and source dispatch together
db: AsyncSession,
user,
query: str = '',
kind: str = 'all',
sharing: str = 'all',
scope: str = '',
page: int = 1,
enabled: tuple[str, ...] = ('chat', 'message', 'note'),
) -> SearchResponse:
groups = [g.id for g in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)]
principal = or_(
and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user.id),
and_(AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(groups)),
)
def granted(resource, resource_id, public=False):
principals = (
or_(principal, and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'))
if public
else principal
)
return (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource,
AccessGrant.resource_id == resource_id,
AccessGrant.permission == 'read',
principals,
)
.exists()
)
# UNION (not UNION ALL) terminates even if malformed folder data contains a cycle.
folders = (
select(Folder.id)
.where(or_(Folder.user_id == user.id, granted('folder', Folder.id, True)))
.cte('search_folders', recursive=True)
)
folders = folders.union(select(Folder.id).join(folders, Folder.parent_id == folders.c.id))
folder_rows = (
await db.execute(
select(Folder.id, Folder.name, Folder.parent_id, Folder.user_id).where(Folder.id.in_(select(folders.c.id)))
)
).all()
folder_map = {f.id: f for f in folder_rows}
channels = await Channels.get_channels_by_user_id(user.id, db=db) if 'message' in enabled else []
channel_map = {c.id: c for c in channels}
channel_names = {c.id: c.name for c in channels}
direct_message_ids = [c.id for c in channels if c.type == 'dm' and not c.name]
if direct_message_ids:
members = (
await db.execute(
select(ChannelMember.channel_id, User.name)
.join(User, User.id == ChannelMember.user_id)
.where(ChannelMember.channel_id.in_(direct_message_ids), User.id != user.id)
.order_by(User.name, User.id)
)
).all()
participants = {channel_id: [] for channel_id in direct_message_ids}
for channel_id, name in members:
participants[channel_id].append(name)
for channel_id, names in participants.items():
channel_names[channel_id] = ', '.join(names) or user.name
scopes = [SearchScope(id='folder:' + f.id, name=f.name, kind='folder') for f in folder_rows]
scopes += [SearchScope(id='channel:' + c.id, name=channel_names[c.id], kind='channel') for c in channels]
scopes.sort(key=lambda s: (s.kind, s.name.lower(), s.id))
shared_folder_ids = set(
(
await db.execute(
select(Folder.id).where(
Folder.id.in_(folder_map),
select(AccessGrant.id)
.where(
AccessGrant.resource_type == 'folder',
AccessGrant.resource_id == Folder.id,
AccessGrant.permission == 'read',
)
.exists(),
)
)
).scalars()
)
while True:
children = {f.id for f in folder_rows if f.parent_id in shared_folder_ids}
if children.issubset(shared_folder_ids):
break
shared_folder_ids |= children
scoped_folders = set(folder_map)
if scope.startswith('folder:'):
root = scope.removeprefix('folder:')
scoped_folders = {root} if root in folder_map else set()
while True:
children = {f.id for f in folder_rows if f.parent_id in scoped_folders}
if children.issubset(scoped_folders):
break
scoped_folders |= children
if scope and scope not in {s.id for s in scopes}:
return SearchResponse(items=[], has_more=False, scopes=scopes, kinds=list(enabled))
query = query.strip()
terms = terms_for(query)
results = []
dialect = db.bind.dialect.name
def sharing_filter(owner):
if sharing == 'mine':
return owner == user.id
if sharing == 'shared':
return owner != user.id
return True
def apply_match(stmt, model, body=None):
params = {}
for index, term in enumerate(terms):
key = f'search_{index}'
params[key] = pattern(term)
content_match = (
body.ilike(pattern(term), escape='\\')
if body is not None
else message_match_sql(model.__tablename__, dialect, key)
)
stmt = stmt.where(or_(model.title.ilike(pattern(term), escape='\\'), content_match))
return stmt.params(**params)
def append_result(id, result_kind, title, context, content, url, updated, shared, pinned=False, rank=3):
results.append(
SearchResult(
id=id,
kind=result_kind,
title=title or 'Untitled',
context=context,
snippet=excerpt(content, query),
preview=excerpt(content, query, 2400),
url=url,
updated_at=updated,
shared=shared,
pinned=bool(pinned),
rank=rank,
)
)
sources = {}
if 'chat' in enabled and kind in ('all', 'chat') and not scope.startswith('channel:'):
continue_shared = select(SharedChat.chat_id).where(
SharedChat.chat['share_mode'].as_string() == 'continue', granted('shared_chat', SharedChat.chat_id)
)
live_access = or_(
Chat.user_id == user.id, Chat.folder_id.in_(select(folders.c.id)), Chat.id.in_(continue_shared)
)
live_ids = select(Chat.id).where(live_access)
rank = (
title_rank(Chat.title, query)
if query
else case((and_(Chat.pinned.is_(True), Chat.user_id == user.id), 0), else_=3)
)
stmt = select(Chat).where(
live_access,
Chat.archived.is_(False),
func.coalesce(Chat.meta['internal'].as_boolean(), False).is_(False),
sharing_filter(Chat.user_id),
)
if scope:
stmt = stmt.where(Chat.folder_id.in_(scoped_folders))
sources['chat'] = (Chat, apply_match(stmt, Chat), rank, Chat.updated_at)
if not scope and sharing != 'mine':
stmt = select(SharedChat).where(
granted('shared_chat', SharedChat.chat_id), ~SharedChat.chat_id.in_(live_ids)
)
if sharing == 'shared':
stmt = stmt.where(SharedChat.user_id != user.id)
sources['shared'] = (
SharedChat,
apply_match(stmt, SharedChat),
title_rank(SharedChat.title, query),
SharedChat.updated_at,
)
if 'note' in enabled and kind in ('all', 'note') and not scope:
pinned = select(PinnedNote.id).where(PinnedNote.note_id == Note.id, PinnedNote.user_id == user.id).exists()
note_shared = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == 'note',
AccessGrant.resource_id == Note.id,
AccessGrant.permission == 'read',
)
.exists()
)
stmt = AccessGrants.has_permission_filter(
db, select(Note, pinned, note_shared), Note, {'user_id': user.id, 'group_ids': groups}, 'note', 'read'
)
stmt = stmt.where(sharing_filter(Note.user_id))
stmt = apply_match(stmt, Note, Note.data['content']['md'].as_string())
rank = title_rank(Note.title, query) if query else case((pinned, 0), else_=3)
sources['note'] = (Note, stmt, rank, cast(Note.updated_at.op('/')(literal(1_000_000_000)), BigInteger))
if 'message' in enabled and kind in ('all', 'message') and not scope.startswith('folder:'):
ids = [scope.removeprefix('channel:')] if scope else list(channel_map)
stmt = select(Message, User.name).outerjoin(User, User.id == Message.user_id).where(Message.channel_id.in_(ids))
stmt = stmt.where(sharing_filter(Message.user_id))
for term in terms:
stmt = stmt.where(Message.content.ilike(pattern(term), escape='\\'))
sources['message'] = (
Message,
stmt,
literal(3),
cast(Message.updated_at.op('/')(literal(1_000_000_000)), BigInteger),
)
if not sources:
return SearchResponse(items=[], has_more=False, scopes=scopes, kinds=list(enabled))
# Page one combined, permission-filtered ID query. Only hydrate the 24 visible
# results, rather than repeatedly loading full conversation JSON while scrolling.
candidates = union_all(
*[
stmt.with_only_columns(
literal(source).label('source'),
model.id.label('id'),
rank.label('rank'),
updated.label('updated_at'),
maintain_column_froms=True,
)
for source, (model, stmt, rank, updated) in sources.items()
]
).subquery()
matches = (
await db.execute(
select(candidates)
.order_by(candidates.c.rank, candidates.c.updated_at.desc(), candidates.c.source, candidates.c.id)
.offset((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE + 1)
)
).all()
has_more = len(matches) > PAGE_SIZE
matches = matches[:PAGE_SIZE]
hydrated = {}
for source, (model, stmt, _, _) in sources.items():
ids = [match.id for match in matches if match.source == source]
if ids:
hydrated[source] = {row[0].id: row for row in (await db.execute(stmt.where(model.id.in_(ids)))).all()}
for match in matches:
row = hydrated.get(match.source, {}).get(match.id)
if row is None:
continue # Access may have been revoked between selecting IDs and hydrating.
item = row[0]
if match.source in ('chat', 'shared'):
content, message_id = chat_passage(item.chat or {}, query)
snapshot = match.source == 'shared'
folder = None if snapshot else folder_map.get(item.folder_id)
context = (
'Shared snapshot'
if snapshot
else (folder.name if folder else ('Shared chat' if item.user_id != user.id else 'Personal chat'))
)
url = ('/s/' if snapshot else '/c/') + item.id
if message_id:
url += '?' + urlencode({'message': message_id})
is_shared = (
snapshot or item.user_id != user.id or bool(item.share_id) or item.folder_id in shared_folder_ids
)
append_result(
match.source + ':' + item.id,
'chat',
item.title,
context,
content,
url,
match.updated_at,
is_shared,
not snapshot and item.pinned and item.user_id == user.id,
match.rank,
)
elif match.source == 'note':
content = ((item.data or {}).get('content') or {}).get('md') or ''
append_result(
'note:' + item.id,
'note',
item.title,
'Shared note' if item.user_id != user.id or row[2] else 'Personal note',
content if isinstance(content, str) else '',
'/notes/' + item.id + ('?' + urlencode({'search': query}) if query else ''),
match.updated_at,
item.user_id != user.id or row[2],
row[1],
match.rank,
)
else:
channel = channel_map[item.channel_id]
url = '/channels/' + channel.id + '?' + urlencode({'thread': item.parent_id or item.id, 'message': item.id})
append_result(
'message:' + item.id,
'message',
row[1] or 'Message',
channel_names[channel.id] if channel.type == 'dm' else '#' + channel_names[channel.id],
item.content,
url,
match.updated_at,
True,
rank=match.rank,
)
return SearchResponse(items=results, has_more=has_more, scopes=scopes, kinds=list(enabled))
@router.get('', response_model=SearchResponse)
async def search(
q: str = Query('', max_length=500),
kind: Literal['all', 'chat', 'message', 'note'] = 'all',
sharing: Literal['all', 'mine', 'shared'] = 'all',
scope: str = Query('', max_length=200),
page: int = Query(1, ge=1, le=100_000),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
enabled = ['chat']
permissions = await Config.get('user.permissions')
for feature, result_kind in [('channels', 'message'), ('notes', 'note')]:
if await Config.get(feature + '.enable') and (
user.role == 'admin' or await has_permission(user.id, 'features.' + feature, permissions, db=db)
):
enabled.append(result_kind)
return await search_workspace(db, user, q, kind, sharing, scope, page, tuple(enabled))

View file

@ -19,7 +19,7 @@ from open_webui.env import (
)
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
@ -571,12 +571,26 @@ async def _update_tool(request, id, form_data, user, db, version_id=None):
specs = get_local_tool_specs(tool_module)
if version_id is None:
form_data.access_grants = await filter_allowed_access_grants(
if version_id is None and form_data.access_grants is not None:
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in tools.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
new_access_grants,
'sharing.public_tools',
)

View file

@ -5,11 +5,13 @@ import io
import logging
import time
from collections import Counter
from copy import deepcopy
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from open_webui.config import DEFAULT_USER_PERMISSIONS
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
@ -37,7 +39,7 @@ from open_webui.models.access_grants import AccessGrants
from open_webui.models.knowledge import Knowledges
from open_webui.models.models import Models
from open_webui.models.tools import Tools
from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.utils.access_control import fill_missing_permissions, get_permissions, has_permission
from open_webui.utils.auth import (
get_admin_user,
get_password_hash,
@ -262,6 +264,7 @@ class FeaturesPermissions(BaseModel):
api_keys: bool = False
notes: bool = True
channels: bool = True
encrypted_channels: bool = False
folders: bool = True
direct_tool_servers: bool = False
@ -412,7 +415,9 @@ def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
@router.get('/default/permissions', response_model=UserPermissions)
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
user_permissions = await Config.get('user.permissions')
user_permissions = fill_missing_permissions(
deepcopy(await Config.get('user.permissions') or {}), DEFAULT_USER_PERMISSIONS
)
return {
'workspace': WorkspacePermissions(**user_permissions.get('workspace', {})),
'sharing': SharingPermissions(**user_permissions.get('sharing', {})),

View file

@ -0,0 +1,432 @@
"""Volatile packet relay. Only account bindings and liveness enter coordination storage."""
import asyncio
import base64
import hashlib
import json
import time
from contextlib import asynccontextmanager
from contextvars import ContextVar
from copy import deepcopy
from uuid import uuid4
from open_webui.config import DEFAULT_USER_PERMISSIONS
from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
ENV,
REDIS_KEY_PREFIX,
WEBSOCKET_SERVER_ENGINEIO_LOGGING,
WEBSOCKET_SERVER_LOGGING,
)
from open_webui.models.channels import Channels
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.utils.access_control import combine_permissions, fill_missing_permissions
MAX_PACKET = 1024 * 1024
TTL = 90
class Rejected(Exception):
pass
def require(condition):
if not condition:
raise Rejected()
def packet(value):
require(isinstance(value, str) and 0 < len(value) <= (MAX_PACKET + 2) // 3 * 4)
try:
decoded = base64.b64decode(value, validate=True)
except (ValueError, TypeError):
raise Rejected() from None
require(0 < len(decoded) <= MAX_PACKET)
return value
class Registry:
def __init__(self, redis=None):
self.redis = redis
self.values = {}
self.lock = asyncio.Lock()
self.lease = ContextVar('encryption_lease', default=None)
def key(self, channel):
digest = hashlib.sha256(channel.encode()).hexdigest()
return f'{REDIS_KEY_PREFIX}:encryption:{{{digest}}}'
@asynccontextmanager
async def transaction(self, channel):
key = self.key(channel)
lock = self.redis.lock(key + ':lock', timeout=10, blocking_timeout=2) if self.redis else self.lock
async with lock:
raw = await self.redis.get(key) if self.redis else self.values.get(key)
state = json.loads(raw) if raw else None
if state and state['expires'] <= time.time():
state = None
token = self.lease.set(lock if self.redis else None)
try:
yield state
finally:
self.lease.reset(token)
async def save(self, channel, state):
key = self.key(channel)
if state is not None:
state['expires'] = time.time() + TTL
raw = json.dumps(state, separators=(',', ':')) if state is not None else ''
if self.redis:
lease = self.lease.get()
require(lease is not None)
# A stalled worker must never overwrite a newer owner's metadata.
changed = await self.redis.eval(
"if redis.call('get', KEYS[2]) ~= ARGV[1] then return 0 end "
"if ARGV[2] == '' then redis.call('del', KEYS[1]) "
"else redis.call('set', KEYS[1], ARGV[2], 'EX', ARGV[3]) end return 1",
2,
key,
key + ':lock',
lease.local.token,
raw,
TTL,
)
if not changed:
raise RuntimeError('Coordination lease expired')
elif state is None:
self.values.pop(key, None)
else:
self.values[key] = raw
class Relay:
def __init__(self, sio, authenticate, redis=None):
self.sio, self.authenticate = sio, authenticate
self.registry = Registry(redis)
self.local = {} # sid -> channel, never payloads
self.rates = {}
for action in ('start', 'status', 'join', 'bind', 'packet', 'leave', 'ping'):
async def handler(sid, data=None, action=action):
return await self.handle(action, sid, data)
sio.on(f'encryption:{action}', handler)
async def authorize(self, sid, channel):
require(ENABLE_WEBSOCKET_SUPPORT and self.sio.transport(sid) == 'websocket')
require(not WEBSOCKET_SERVER_LOGGING and not WEBSOCKET_SERVER_ENGINEIO_LOGGING)
scope = (self.sio.get_environ(sid) or {}).get('asgi.scope', {})
require(ENV != 'prod' or scope.get('scheme') in ('https', 'wss'))
auth = await self.authenticate(sid, wait_for_disconnect=False)
require(auth and auth.get('id'))
user = await Users.get_user_by_id(auth['id'])
require(user and user.role in ('user', 'admin'))
record = await Channels.get_channel_by_id(channel)
require(record and (record.type == 'dm' or (record.type == 'group' and record.is_private is True)))
require(await Channels.is_user_channel_member(channel, user.id))
return user
@staticmethod
def view(state):
if not state:
return None
return {
'channel': state['channel'],
'session': state['session'],
'host': state['host'],
'bindings': [
{
'identity': {
'channel': state['channel'],
'session': state['session'],
'user': p['user'],
'participation': pid,
},
'key': p['key'],
}
for pid, p in state['members'].items()
if p.get('key')
],
'participants': [
{'participation': pid, 'user': p['user'], 'name': p['name']} for pid, p in state['members'].items()
],
}
async def invite(self, state, channel):
await self.sio.emit(
'encryption:available',
{'channel': channel, 'session': state['session'] if state else None},
room=f'channel:{channel}',
)
async def deliver(self, state, recipients, event):
# Per-recipient sequence detects missing Pub/Sub deliveries without storing packets.
targets = []
for pid in recipients:
member = state['members'][pid]
member['out'] += 1
targets.append((member['sid'], member['out']))
await self.registry.save(state['channel'], state)
for sid, sequence in targets:
await self.sio.emit('encryption:event', {**event, **self.view(state), 'sequence': sequence}, to=sid)
async def close(self, channel, state):
await self.registry.save(channel, None)
if state:
for p in state['members'].values():
self.local.pop(p['sid'], None)
await self.sio.emit(
'encryption:event', {'kind': 'ended', 'channel': channel, 'session': state['session']}, to=p['sid']
)
await self.invite(None, channel)
async def eligible_users(self, user_ids):
config = await Config.get_many('channels.enable', 'channels.encryption.enable', 'user.permissions')
if not config['channels.enable'] or not config['channels.encryption.enable']:
return {}
defaults = fill_missing_permissions(deepcopy(config['user.permissions'] or {}), DEFAULT_USER_PERMISSIONS)
users = await Users.get_users_by_user_ids(list(set(user_ids)))
groups = await Groups.get_groups_by_member_ids(
[u.id for u in users if u.role == 'user'], include_inherited=True
)
eligible = {}
for user in users:
if user.role == 'admin':
eligible[user.id] = user
elif user.role == 'user':
permissions = deepcopy(defaults)
for group in groups[user.id]:
combine_permissions(permissions, group.permissions or {})
features = permissions.get('features', {})
if features.get('channels') and features.get('encrypted_channels'):
eligible[user.id] = user
return eligible
async def prune(self, state, eligible=None):
if not state:
return None
if eligible is None:
eligible = await self.eligible_users([p['user'] for p in state['members'].values()])
members = await Channels.get_members_by_channel_id(state['channel'])
active = {m.user_id for m in members if m.is_active}
stale = [
pid
for pid, p in state['members'].items()
if p['seen'] < time.time() - 45 or p['user'] not in active or p['user'] not in eligible
]
if state['host'] in stale:
await self.close(state['channel'], state)
return None
if stale:
await self.remove(state, *stale)
return state
async def remove(self, state, *pids):
if state['host'] in pids:
await self.close(state['channel'], state)
return
# Exclude every revoked recipient before any departure event is forwarded.
removed = [(pid, state['members'].pop(pid)) for pid in pids]
for pid, p in removed:
self.local.pop(p['sid'], None)
await self.sio.emit(
'encryption:event',
{'kind': 'ended', 'channel': state['channel'], 'session': state['session']},
to=p['sid'],
)
await self.deliver(
state,
[i for i, m in state['members'].items() if m['admitted']],
{'kind': 'departed', 'participation': pid, 'admitted': p['admitted']},
)
async def limit(self, user, action, size):
now = time.monotonic()
if len(self.rates) > 10000:
self.rates = {k: v for k, v in self.rates.items() if now - v[0] < 60}
if self.registry.redis:
bucket = 'admission' if action in ('start', 'join') else 'traffic'
period, maximum = (60, 10) if bucket == 'admission' else (1, 100)
key = self.registry.key('rate:' + user + ':' + bucket)
allowed = await self.registry.redis.eval(
"local n = redis.call('hincrby', KEYS[1], 'count', 1) "
"local b = redis.call('hincrby', KEYS[1], 'bytes', ARGV[1]) "
"if n == 1 then redis.call('expire', KEYS[1], ARGV[2]) end "
'if n > tonumber(ARGV[3]) or b > tonumber(ARGV[4]) then return 0 end return 1',
1,
key,
size,
period,
maximum,
4 * MAX_PACKET,
)
require(allowed)
return
# Account-wide admission bound also applies across tabs on this worker.
key = (user, 'admission' if action in ('start', 'join') else 'traffic')
period, maximum = (60, 10) if key[1] == 'admission' else (1, 100)
start, count, total = self.rates.get(key, (now, 0, 0))
if now - start >= period:
start, count, total = now, 0, 0
require(count < maximum and total + size <= 4 * MAX_PACKET)
self.rates[key] = (start, count + 1, total + size)
async def reserve(self, action, sid, user, channel, data, state):
# Another worker may have closed the session; its metadata is authoritative.
if self.local.get(sid) == channel and (
not state or not any(p['sid'] == sid for p in state['members'].values())
):
self.local.pop(sid, None)
require(sid not in self.local)
if action == 'start':
require(state is None)
pid = str(uuid4())
state = {'channel': channel, 'session': str(uuid4()), 'host': pid, 'members': {}}
else:
require(state and data.get('session') == state['session'])
require(len(state['members']) < 128 and not any(p['user'] == user.id for p in state['members'].values()))
require(state['members'][state['host']].get('key'))
pid = str(uuid4())
state['members'][pid] = {
'sid': sid,
'user': user.id,
'name': user.name,
'key': None,
'seen': time.time(),
'out': 0,
'admitted': action == 'start',
}
self.local[sid] = channel
await self.registry.save(channel, state)
return {'ok': True, **self.view(state), 'participation': pid}
async def bind(self, state, pid, member, data):
require(member['key'] is None)
key = packet(data.get('key'))
require(len(base64.b64decode(key)) == 32)
member['key'] = key
if pid != state['host']:
kp = packet(data.get('packet'))
await self.deliver(state, [state['host']], {'kind': 'candidate', 'sender': pid, 'packet': kp})
else:
await self.registry.save(state['channel'], state)
await self.invite(state, state['channel'])
async def forward(self, state, pid, member, data):
require(member['key'] and member['admitted'])
body = packet(data.get('packet'))
if data.get('kind') == 'admit':
require(pid == state['host'])
target = data.get('target')
joining = state['members'].get(target)
require(joining and joining.get('key') and not joining['admitted'])
welcome = packet(data.get('welcome'))
existing = [i for i, p in state['members'].items() if i != pid and p['admitted']]
joining['admitted'] = True
await self.deliver(state, existing, {'kind': 'mls', 'sender': pid, 'packet': body})
await self.deliver(state, [target], {'kind': 'welcome', 'sender': pid, 'packet': welcome})
else:
require(data.get('kind') == 'mls')
recipients = [i for i, p in state['members'].items() if i != pid and p['admitted']]
await self.deliver(state, recipients, {'kind': 'mls', 'sender': pid, 'packet': body})
async def handle(self, action, sid, data):
channel = None
authorized = False
try:
require(isinstance(data, dict))
channel = data.get('channel')
require(isinstance(channel, str) and 0 < len(channel) <= 128)
size = len(json.dumps(data))
require(size <= 3 * MAX_PACKET)
user = await self.authorize(sid, channel)
authorized = True
await self.limit(user.id, action, size)
async with self.registry.transaction(channel) as state:
# Read current policy after waiting for the channel's coordination lock.
eligible = await self.eligible_users(
[user.id, *([p['user'] for p in state['members'].values()] if state else [])]
)
state = await self.prune(state, eligible)
require(user.id in eligible)
user = eligible[user.id]
require(await Channels.is_user_channel_member(channel, user.id))
if action == 'status':
return {
'ok': True,
'available': bool(state and state['members'][state['host']].get('key')),
'session': state['session'] if state else None,
}
if action in ('start', 'join'):
return await self.reserve(action, sid, user, channel, data, state)
require(state and data.get('session') == state['session'] and self.local.get(sid) == channel)
pid = data.get('participation')
member = state['members'].get(pid)
require(member and member['sid'] == sid and member['user'] == user.id)
member['seen'] = time.time()
if action == 'leave':
await self.remove(state, pid)
return {'ok': True}
if action == 'bind':
await self.bind(state, pid, member, data)
elif action == 'ping':
await self.registry.save(channel, state)
elif action == 'packet':
await self.forward(state, pid, member, data)
else:
raise Rejected()
return {'ok': True, **self.view(state)}
except Rejected:
return {'ok': False, 'error': 'Private chat is unavailable or the session changed.'}
except Exception:
# Never log exceptions carrying packet input. Fail closed on coordinator failure.
if authorized:
await self.fail_local(channel)
return {'ok': False, 'error': 'Private chat ended.'}
async def fail_local(self, channel):
for sid, local_channel in list(self.local.items()):
if channel is None or local_channel == channel:
self.local.pop(sid, None)
await self.sio.emit(
'encryption:event', {'kind': 'ended', 'channel': local_channel}, to=sid, ignore_queue=True
)
async def disconnect(self, sid):
channel = self.local.pop(sid, None)
if not channel:
return
try:
async with self.registry.transaction(channel) as state:
if state:
for pid, p in list(state['members'].items()):
if p['sid'] == sid:
await self.remove(state, pid)
break
except Exception:
await self.fail_local(channel)
async def invalidate(self, channel):
# Channel access mutations end the session before any further private sends.
try:
async with self.registry.transaction(channel) as state:
await self.close(channel, state)
except Exception:
await self.fail_local(channel)
async def sweep(self):
for channel in set(self.local.values()):
try:
async with self.registry.transaction(channel) as state:
if not state:
await self.fail_local(channel)
else:
await self.prune(state)
except Exception:
await self.fail_local(channel)
if not self.registry.redis:
for key, raw in list(self.registry.values.items()):
if json.loads(raw)['expires'] <= time.time():
self.registry.values.pop(key, None)

View file

@ -42,6 +42,7 @@ from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.notes import Notes, NoteUpdateForm
from open_webui.models.users import UserNameResponse, Users
from open_webui.socket.encryption import Relay
from open_webui.socket.redis_room_channels import AsyncRedisRoomChannelManager
from open_webui.socket.utils import SOCKET_EVENT_LOCKS, CachedRedisDict, RedisDict, RedisLock, YdocManager
from open_webui.tasks import (
@ -231,6 +232,47 @@ def get_session_pool_batches():
return [list(SESSION_POOL.items())]
async def publish_chat_presence(chat_id: str):
# Room maps only contain this worker's sockets; the session pool spans workers.
lock = (
REDIS.lock(
f'{REDIS_KEY_PREFIX}:chat:{chat_id}:presence:lock',
timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT,
blocking_timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT,
)
if REDIS is not None
else SOCKET_EVENT_LOCKS.setdefault(('chat', chat_id), asyncio.Lock())
)
try:
async with lock, asyncio.timeout(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2):
now = time.time()
people = {}
for batch in get_session_pool_batches():
for _, session in batch:
if (
session
and chat_id in (session.get('chat_ids') or [])
and now - session.get('last_seen_at', 0) <= SESSION_POOL_TIMEOUT
):
people[session['id']] = {'id': session['id'], 'name': session.get('name', '')}
if REDIS is not None and not await lock.owned():
return
await sio.emit(
'events',
{
'chat_id': chat_id,
'shared': True,
'data': {
'type': 'chat:presence',
'data': {'users': sorted(people.values(), key=lambda person: person['id'])},
},
},
room=f'chat:{chat_id}',
)
except Exception:
log.exception('Failed to publish presence for chat %s', chat_id)
async def periodic_session_pool_cleanup():
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
@ -257,11 +299,23 @@ async def periodic_session_pool_cleanup():
]
if expired:
log.warning('Reaping %d orphaned session(s) from the session pool', len(expired))
chat_ids = {
chat_id
for sid, entry in batch
if sid in expired
for chat_id in entry.get('chat_ids', [])
}
if WEBSOCKET_MANAGER == 'redis':
SESSION_POOL.delete_many(*expired)
else:
for sid in expired:
SESSION_POOL.pop(sid, None)
for sid, entry in batch:
if sid in expired:
for chat_id in entry.get('chat_ids', []):
await sio.leave_room(sid, f'chat:{chat_id}')
for chat_id in chat_ids:
await publish_chat_presence(chat_id)
await asyncio.sleep(0) # don't hold the loop for the whole sweep
next_cleanup_at = time.monotonic() + SESSION_POOL_TIMEOUT
@ -355,6 +409,7 @@ LOCAL_AUTHENTICATED_SIDS: set[str] = set()
async def periodic_socket_authentication():
while True:
await asyncio.sleep(30)
await encryption.sweep()
for sid in tuple(LOCAL_AUTHENTICATED_SIDS):
await get_socket_session_user(sid)
@ -376,6 +431,9 @@ async def get_socket_session_user(sid: str, *, wait_for_disconnect: bool = True)
return None
encryption = Relay(sio, get_socket_session_user, REDIS)
def get_session_ids_from_room(room):
"""Get all session IDs from a specific room."""
members = get_room_sid_map(sio.manager, '/', room)
@ -562,12 +620,12 @@ async def user_join(sid, data):
async def refresh_chat_access(chat_id=None):
# The pool includes sessions on other workers; Socket.IO routes room changes via Redis.
access = {}
chat_ids = set()
for batch in get_session_pool_batches():
for sid, session in batch:
if not session:
continue
chat_ids = set(session.get('chat_ids') or [])
for cid in list(chat_ids):
for cid in session.get('chat_ids') or []:
if chat_id and cid != chat_id:
continue
key = (cid, session['id'])
@ -576,12 +634,19 @@ async def refresh_chat_access(chat_id=None):
access[key] = bool(user and await Chats.get_accessible_chat_by_id(cid, user))
if not access[key]:
await sio.leave_room(sid, f'chat:{cid}')
chat_ids.discard(cid)
chat_ids.add(cid)
# Preserve joins/leaves and heartbeats that happened during access checks.
current = SESSION_POOL.get(sid)
if current:
SESSION_POOL[sid] = {
**current,
'chat_ids': [id for id in current.get('chat_ids', []) if id != cid],
}
await sio.emit(
'events', {'chat_id': cid, 'shared': True, 'data': {'type': 'chat:access', 'data': {}}}, to=sid
)
if chat_ids != set(session.get('chat_ids') or []):
SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)}
for cid in chat_ids:
await publish_chat_presence(cid)
@sio.on('heartbeat')
@ -745,18 +810,25 @@ async def chat_events(sid, data):
chat_id = data.get('chat_id')
if not isinstance(chat_id, str) or not is_saved_chat_id(chat_id):
return False
session = SESSION_POOL.get(sid) or user
chat_ids = set(session.get('chat_ids') or [])
if event_type == 'leave':
await sio.leave_room(sid, f'chat:{chat_id}')
chat_ids.discard(chat_id)
else:
reader = await Users.get_user_by_id(user['id'])
if not reader or not await Chats.get_accessible_chat_by_id(chat_id, reader):
async with SOCKET_EVENT_LOCKS.setdefault(('session', sid), asyncio.Lock()):
if event_type == 'leave':
await sio.leave_room(sid, f'chat:{chat_id}')
else:
reader = await Users.get_user_by_id(user['id'])
if not reader or not await Chats.get_accessible_chat_by_id(chat_id, reader):
return False
await sio.enter_room(sid, f'chat:{chat_id}')
session = SESSION_POOL.get(sid)
if not session:
await sio.leave_room(sid, f'chat:{chat_id}')
return False
await sio.enter_room(sid, f'chat:{chat_id}')
chat_ids.add(chat_id)
SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)}
chat_ids = set(session.get('chat_ids') or [])
if event_type == 'leave':
chat_ids.discard(chat_id)
else:
chat_ids.add(chat_id)
SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)}
await publish_chat_presence(chat_id)
return True
if event_type == 'last_read_at':
@ -1128,9 +1200,14 @@ async def yjs_awareness_update(sid, data):
@sio.event
async def disconnect(sid, reason=None):
await encryption.disconnect(sid)
LOCAL_AUTHENTICATED_SIDS.discard(sid)
if sid in SESSION_POOL:
chat_ids = (SESSION_POOL.get(sid) or {}).get('chat_ids', [])
del SESSION_POOL[sid]
for chat_id in chat_ids:
await sio.leave_room(sid, f'chat:{chat_id}')
await publish_chat_presence(chat_id)
# Clean up USAGE_POOL entries for this session
for model_id, connections in list(USAGE_POOL.items()):
@ -1279,7 +1356,7 @@ async def _make_channel_emitter(request_info):
await _emit_channel_update(content, done, output if isinstance(output, list) else None)
elif event_type == 'response:completion':
from open_webui.utils.middleware import handle_responses_streaming_event
from open_webui.utils.response import handle_responses_streaming_event
data = event_data.get('data', {})
state['output'], _ = handle_responses_streaming_event(data, state['output'])
@ -1374,7 +1451,7 @@ async def get_event_emitter(request_info, update_db=True):
if isinstance(data.get('output'), list):
output = copy.deepcopy(data['output'])
elif event_type == 'response:completion':
from open_webui.utils.middleware import handle_responses_streaming_event
from open_webui.utils.response import handle_responses_streaming_event
output, _ = handle_responses_streaming_event(data, output or [])
now = time.monotonic()

View file

@ -11,7 +11,7 @@ class AsyncRedisRoomChannelManager(AsyncRedisManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._local_room_channels = set()
self._subscription_queue = asyncio.Queue()
# collision-free while namespaces contain no '#' (socket.io default '/'); rooms may contain '#'
def _room_channel(self, namespace, room):
@ -20,12 +20,29 @@ class AsyncRedisRoomChannelManager(AsyncRedisManager):
def basic_enter_room(self, sid, namespace, room, eio_sid=None):
super().basic_enter_room(sid, namespace, room, eio_sid=eio_sid)
if room is not None:
self._local_room_channels.add(self._room_channel(namespace, room))
self._subscription_queue.put_nowait(asyncio.Event())
def basic_leave_room(self, sid, namespace, room):
super().basic_leave_room(sid, namespace, room)
if room is not None and room not in self.rooms.get(namespace, {}):
self._local_room_channels.discard(self._room_channel(namespace, room))
self._subscription_queue.put_nowait(asyncio.Event())
async def _wait_for_rooms(self):
ready = asyncio.Event()
self._subscription_queue.put_nowait(ready)
await ready.wait()
async def connect(self, eio_sid, namespace):
sid = await super().connect(eio_sid, namespace)
if sid is not None:
await self._wait_for_rooms()
return sid
async def enter_room(self, sid, namespace, room, eio_sid=None):
local = self.is_connected(sid, namespace)
await super().enter_room(sid, namespace, room, eio_sid=eio_sid)
if local and room is not None:
await self._wait_for_rooms()
async def _publish(self, data):
if data.get('method') == 'emit' and isinstance(data.get('room'), str):
@ -57,23 +74,73 @@ class AsyncRedisRoomChannelManager(AsyncRedisManager):
)
break
async def _sync_rooms(self, pubsub):
wanted = {self.channel.encode()} | {
self._room_channel(namespace, room)
for namespace, rooms in self.rooms.items()
for room in rooms
if room is not None
}
if added := wanted - (pubsub.channels.keys() - pubsub.pending_unsubscribe_channels):
await pubsub.subscribe(*added)
if removed := pubsub.channels.keys() - wanted - pubsub.pending_unsubscribe_channels:
await pubsub.unsubscribe(*removed)
async def _listen_to_rooms(self, pubsub):
pubsub.ignore_subscribe_messages = False
self._subscription_queue.put_nowait(asyncio.Event())
changed = asyncio.create_task(self._subscription_queue.get())
message = asyncio.create_task(pubsub.get_message(timeout=None))
waiters = []
serial = 0
try:
while True:
await asyncio.wait([message] if waiters else [message, changed], return_when=asyncio.FIRST_COMPLETED)
if not waiters and changed.done():
waiters.append(changed.result())
while not self._subscription_queue.empty():
waiters.append(self._subscription_queue.get_nowait())
changed = asyncio.create_task(self._subscription_queue.get())
await self._sync_rooms(pubsub)
# PONG confirms Redis processed the subscriptions before a join returns.
serial += 1
await pubsub.ping(str(serial).encode())
if message.done():
data = message.result() or {}
message = asyncio.create_task(pubsub.get_message(timeout=None))
if data.get('type') == 'subscribe':
# redis-py can reconnect internally and restore a pending removal.
self._subscription_queue.put_nowait(asyncio.Event())
elif data.get('type') == 'pong' and data['data'] == str(serial).encode():
for ready in waiters:
ready.set()
waiters.clear()
else:
yield data
finally:
if changed.done() and not changed.cancelled():
waiters.append(changed.result())
for ready in waiters:
self._subscription_queue.put_nowait(ready)
message.cancel()
changed.cancel()
await asyncio.gather(message, changed, return_exceptions=True)
await pubsub.aclose()
async def _redis_listen_with_retries(self):
_, error = self._get_redis_module_and_error()
retry_sleep = 1
subscribed = False
while True:
try:
if not subscribed:
self._redis_connect()
await self.pubsub.subscribe(self.channel)
await self.pubsub.psubscribe(f'{self.channel}#*')
retry_sleep = 1
async for message in self.pubsub.listen():
self._redis_connect()
await self.pubsub.subscribe(self.channel)
retry_sleep = 1
async for message in self._listen_to_rooms(self.pubsub):
yield message
except error as exc:
if isinstance(exc, NoPermissionError):
self._get_logger().error(
'Redis denied subscribing: %s. Check SUBSCRIBE/PSUBSCRIBE permissions and channel ACLs '
'Redis denied subscribing: %s. Check SUBSCRIBE/UNSUBSCRIBE/PING permissions and channel ACLs '
'(&%s and &%s#*). To disable room channels, set '
'WEBSOCKET_REDIS_ROOM_CHANNELS=False on every instance and fully restart the fleet.',
exc,
@ -84,18 +151,12 @@ class AsyncRedisRoomChannelManager(AsyncRedisManager):
f'Cannot receive from redis... retrying in {retry_sleep} secs',
extra={'redis_exception': str(exc)},
)
subscribed = False
await asyncio.sleep(retry_sleep)
retry_sleep *= 2
if retry_sleep > 60:
retry_sleep = 60
async def _listen(self):
main_channel = self.channel.encode()
async for message in self._redis_listen_with_retries():
if 'data' not in message:
continue
if (message['type'] == 'message' and message['channel'] == main_channel) or (
message['type'] == 'pmessage' and message['channel'] in self._local_room_channels
):
if message.get('type') == 'message':
yield message['data']

View file

@ -2,12 +2,16 @@
import asyncio
import logging
from contextlib import suppress
from itertools import groupby
from uuid import uuid4
from redis.asyncio import Redis
from redis.exceptions import RedisError
from open_webui.env import REDIS_KEY_PREFIX, REDIS_RESPONSE_STREAM_TTL, REDIS_TASK_TTL
from open_webui.utils.json_codec import JSONCodec, dumps_bytes
from open_webui.utils.misc import get_output_text
from open_webui.utils.response import get_response_delta_key, handle_responses_streaming_event
log = logging.getLogger(__name__)
@ -99,6 +103,7 @@ async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None):
pipe.delete(f'{REDIS_TASKS_KEY}:{task_id}')
pipe.hdel(REDIS_TASKS_KEY, task_id)
pipe.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id)
pipe.delete(f'{REDIS_RESPONSE_STREAMS_KEY}:{task_id}')
if item_id:
pipe.srem(f'{REDIS_ITEM_TASKS_KEY}:{item_id}', task_id)
await pipe.execute()
@ -201,26 +206,62 @@ async def save_response_stream(
task_id: str | None,
chat_id: str | None,
message_id: str | None,
content: str,
output: list,
):
) -> int:
"""Save a snapshot and return its byte size; zero means it needs to be retried."""
if not task_id or not chat_id or not message_id:
return
return 0
data = {
'chat_id': chat_id,
'message_id': message_id,
'content': content,
'output': output,
}
if redis:
await redis.hset(REDIS_RESPONSE_STREAMS_KEY, task_id, dumps_bytes(data))
if REDIS_RESPONSE_STREAM_TTL > 0:
with suppress(Exception):
await redis.hexpire(REDIS_RESPONSE_STREAMS_KEY, REDIS_RESPONSE_STREAM_TTL, task_id)
snapshot = dumps_bytes(data) + b'\n'
try:
await redis.set(
f'{REDIS_RESPONSE_STREAMS_KEY}:{task_id}',
snapshot,
ex=REDIS_RESPONSE_STREAM_TTL if REDIS_RESPONSE_STREAM_TTL > 0 else None,
)
except RedisError:
return 0
return len(snapshot)
else:
response_streams[task_id] = data
return 0
async def append_response_stream(redis: Redis, task_id: str, delta: bytes, size: int) -> bool:
"""Append only to the snapshot we last wrote, including after a Redis retry."""
try:
return bool(
await redis.eval(
"if redis.call('strlen', KEYS[1]) ~= tonumber(ARGV[1]) then return 0 end "
"redis.call('append', KEYS[1], ARGV[2]) "
"if tonumber(ARGV[3]) > 0 then redis.call('expire', KEYS[1], ARGV[3]) end return 1",
1,
f'{REDIS_RESPONSE_STREAMS_KEY}:{task_id}',
size,
delta,
REDIS_RESPONSE_STREAM_TTL,
)
)
except RedisError:
return False
def load_response_stream(value: str | bytes) -> dict:
lines = value.split(b'\n' if isinstance(value, bytes) else '\n')
snapshot, *deltas = (JSONCodec.loads(line) for line in lines if line)
# Join adjacent text deltas before applying them, avoiding a full text copy per token.
for _, events in groupby(deltas, key=get_response_delta_key):
first, *rest = events
delta = {**first, 'delta': first['delta'] + ''.join(event['delta'] for event in rest)}
snapshot['output'], _ = handle_responses_streaming_event(delta, snapshot['output'])
return snapshot
async def get_response_streams_by_chat_id(redis, chat_id: str) -> list[dict]:
@ -229,21 +270,31 @@ async def get_response_streams_by_chat_id(redis, chat_id: str) -> list[dict]:
return []
if redis:
values = await redis.hmget(REDIS_RESPONSE_STREAMS_KEY, task_ids)
pipe = redis.pipeline(transaction=False)
for task_id in task_ids:
pipe.get(f'{REDIS_RESPONSE_STREAMS_KEY}:{task_id}')
values = await pipe.execute()
# Replies started on older instances still use the shared hash during an upgrade.
if any(value is None for value in values):
legacy = await redis.hmget(REDIS_RESPONSE_STREAMS_KEY, task_ids)
values = [value if value is not None else old for value, old in zip(values, legacy)]
streams = []
for value in values:
if not value:
continue
try:
data = JSONCodec.loads(value)
data = load_response_stream(value)
except Exception:
continue
if data.get('chat_id') == chat_id:
data['content'] = get_output_text(data['output'])
streams.append(data)
return streams
return [
stream for task_id in task_ids if (stream := response_streams.get(task_id)) and stream.get('chat_id') == chat_id
{**stream, 'content': get_output_text(stream['output'])}
for task_id in task_ids
if (stream := response_streams.get(task_id)) and stream.get('chat_id') == chat_id
]
@ -251,7 +302,10 @@ async def clear_response_stream(redis, task_id: str | None):
if not task_id:
return
if redis:
await redis.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id)
pipe = redis.pipeline(transaction=False)
pipe.delete(f'{REDIS_RESPONSE_STREAMS_KEY}:{task_id}')
pipe.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id)
await pipe.execute()
else:
response_streams.pop(task_id, None)

View file

@ -3550,7 +3550,7 @@ async def view_skill(
{
'id': skill.id,
'name': snapshot['name'],
**skill_overview(snapshot['content'], file_summaries(snapshot['data']['files'])),
**await skill_overview(snapshot['content'], file_summaries(snapshot['data']['files'])),
},
ensure_ascii=False,
)
@ -3573,7 +3573,7 @@ async def read_skill_file(
:param id: Skill ID.
:param path: Relative path within the skill.
:param offset: Character offset for paging text.
:param max_chars: Maximum characters to return, up to 20000 (default 10000).
:param max_chars: Maximum characters to return, capped by the admin skill content limit (default 10000).
"""
try:
from types import SimpleNamespace
@ -3627,7 +3627,7 @@ async def read_skill_file(
return JSONCodec.dumps(
{
'path': path,
**skill_content_page(file['content'], offset, max_chars),
**await skill_content_page(file['content'], offset, max_chars),
},
ensure_ascii=False,
)

View file

@ -1,4 +1,5 @@
import logging
from copy import deepcopy
from typing import Any
from open_webui.config import DEFAULT_USER_PERMISSIONS
@ -12,7 +13,6 @@ from open_webui.models.access_grants import (
)
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
from open_webui.utils.json_codec import JSONCodec
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@ -25,7 +25,7 @@ def fill_missing_permissions(permissions: dict[str, Any], default_permissions: d
"""
for key, value in default_permissions.items():
if key not in permissions:
permissions[key] = value
permissions[key] = deepcopy(value)
elif isinstance(value, dict) and isinstance(permissions[key], dict): # Both are nested dictionaries
permissions[key] = fill_missing_permissions(permissions[key], value)
@ -61,15 +61,12 @@ async def get_permissions(
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
# Deep copy default permissions to avoid modifying the original dict
permissions = JSONCodec.loads(JSONCodec.dumps(default_permissions))
permissions = fill_missing_permissions(deepcopy(default_permissions or {}), DEFAULT_USER_PERMISSIONS)
# Combine permissions from all user groups
for group in user_groups:
permissions = combine_permissions(permissions, group.permissions or {})
# Ensure all fields from default_permissions are present and filled in
permissions = fill_missing_permissions(permissions, default_permissions)
return permissions
@ -105,7 +102,7 @@ async def has_permission(
return True
# Check default permissions afterward if the group permissions don't allow it
default_permissions = fill_missing_permissions(default_permissions, DEFAULT_USER_PERMISSIONS)
default_permissions = fill_missing_permissions(deepcopy(default_permissions or {}), DEFAULT_USER_PERMISSIONS)
return get_permission(default_permissions, permission_hierarchy)

View file

@ -264,8 +264,8 @@ class AuditLoggingMiddleware:
return False # Do NOT skip logging for auth endpoints
# Skip logging if the request is not authenticated
# Check both Authorization header (API keys) and token cookie (browser sessions)
if not request.headers.get('authorization') and not request.cookies.get('token'):
# Token is set by AppHTTPMiddleware from any accepted credential
if not getattr(request.state, 'token', None):
return True
# Whitelist mode: only log paths that match included_paths

View file

@ -81,7 +81,7 @@ from open_webui.socket.main import (
get_event_call,
get_event_emitter,
)
from open_webui.tasks import clear_response_stream, save_response_stream
from open_webui.tasks import append_response_stream, clear_response_stream, save_response_stream
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
from open_webui.utils.access_control.folders import has_folder_access
@ -102,7 +102,7 @@ from open_webui.utils.filter import (
get_filter_functions,
process_filter_functions,
)
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.json_codec import JSONCodec, dumps_bytes
from open_webui.utils.memory import add_memory_context, review_memory_after_turn
from open_webui.utils.misc import (
add_or_update_system_message,
@ -129,7 +129,12 @@ from open_webui.utils.misc import (
from open_webui.utils.oauth import get_system_oauth_token
from open_webui.utils.payload import apply_params_to_form_data, apply_system_prompt_to_body, resolve_system_prompt
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.response import merge_usage, normalize_usage
from open_webui.utils.response import (
get_response_delta_key,
handle_responses_streaming_event,
merge_usage,
normalize_usage,
)
from open_webui.utils.sanitize import sanitize_code
from open_webui.utils.skills import (
apply_skills_create_prompt,
@ -587,27 +592,6 @@ def get_citation_source_from_tool_result(
]
def deep_merge(target, source):
"""
Merge source into target recursively (returning new structure).
- Dicts: Recursive merge.
- Strings: Concatenation.
- Others: Overwrite.
"""
if isinstance(target, dict) and isinstance(source, dict):
new_target = target.copy()
for k, v in source.items():
if k in new_target:
new_target[k] = deep_merge(new_target[k], v)
else:
new_target[k] = v
return new_target
elif isinstance(target, str) and isinstance(source, str):
return target + source
else:
return source
RESPONSE_COMPLETION_RESPONSE_FIELDS = ('error', 'id', 'output', 'usage')
@ -625,370 +609,6 @@ def get_response_completion_event_data(event: dict) -> dict:
}
def handle_responses_streaming_event(
data: dict,
current_output: list,
) -> tuple[list, dict | None]:
"""
Handle Responses API streaming events in a pure functional way.
Args:
data: The event data
current_output: List of output items (treated as immutable)
Returns:
tuple[list, dict | None]: (new_output, metadata)
- new_output: The updated output list.
- metadata: Metadata to emit (e.g. usage), {} if update occurred, None if skip.
"""
# Default: no change
# Note: treating current_output as immutable, but avoiding full deepcopy for perf.
# We will shallow copy only if we need to modify the list structure or items.
event_type = data.get('type', '')
if event_type == 'response.output_item.added':
item = data.get('item', {})
if item:
new_output = list(current_output)
output_index = data.get('output_index', len(new_output))
existing_index = next(
(
idx
for idx, existing in enumerate(new_output)
if (item.get('id') and existing.get('id') == item.get('id'))
or (
item.get('call_id')
and existing.get('type') == item.get('type')
and existing.get('call_id') == item.get('call_id')
)
),
None,
)
if existing_index is not None:
new_output[existing_index] = item
elif 0 <= output_index < len(new_output):
new_output.insert(output_index, item)
else:
new_output.append(item)
return new_output, None
return current_output, None
elif event_type == 'response.content_part.added':
part = data.get('part', {})
output_index = data.get('output_index', len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
# Copy the item to mutate it
item = new_output[output_index].copy()
new_output[output_index] = item
if 'content' not in item:
item['content'] = []
else:
# Copy content list
item['content'] = list(item['content'])
if item.get('type') == 'reasoning':
# Reasoning items should not have content parts
pass
else:
item['content'].append(part)
return new_output, None
return current_output, None
elif event_type == 'response.reasoning_summary_part.added':
part = data.get('part', {})
output_index = data.get('output_index', len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if 'summary' not in item:
item['summary'] = []
else:
item['summary'] = list(item['summary'])
item['summary'].append(part)
return new_output, None
return current_output, None
elif event_type.startswith('response.') and event_type.endswith('.delta'):
# Generic Delta Handling
parts = event_type.split('.')
if len(parts) >= 3:
delta_type = parts[1]
delta = data.get('delta', '')
output_index = data.get('output_index', max(len(current_output) - 1, 0))
# Chat Completions can start an item with a delta, without an added event.
if output_index >= len(current_output):
current_output = list(current_output)
while len(current_output) <= output_index:
current_output.append(
{'type': 'message', 'status': 'in_progress', 'role': 'assistant', 'content': []}
)
current_output[output_index].update(
id=data.get('item_id'),
type='reasoning'
if delta_type.startswith('reasoning')
else ('function_call' if delta_type == 'function_call_arguments' else 'message'),
)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
item_type = item.get('type', '')
# Determine target field and object based on delta_type and item_type
if delta_type == 'function_call_arguments':
key = 'arguments'
if item_type == 'function_call':
# Function call args are usually strings
item[key] = item.get(key, '') + str(delta)
else:
# Generic handling, refined by item type below
pass
if item_type == 'message':
# Message items: "text"/"output_text" -> "text"
# "reasoning_text" -> Skipped (should use reasoning item)
if delta_type in ['text', 'output_text']:
key = 'text'
elif delta_type in ['reasoning_text', 'reasoning_summary_text']:
# Skip reasoning updates for message items
return new_output, None
else:
key = delta_type
content_index = data.get('content_index', 0)
if 'content' not in item:
item['content'] = []
else:
item['content'] = list(item['content'])
content_list = item['content']
while len(content_list) <= content_index:
content_list.append({'type': 'text', 'text': ''})
# Copy the part to mutate it
part = content_list[content_index].copy()
content_list[content_index] = part
current_val = part.get(key)
if current_val is None:
# Initialize based on delta type
current_val = {} if isinstance(delta, dict) else ''
part[key] = deep_merge(current_val, delta)
elif item_type == 'reasoning':
# Reasoning items: "reasoning_text"/"reasoning_summary_text" -> "text"
# "text"/"output_text" -> Skipped (should use message item)
if delta_type == 'reasoning_summary_text':
# Summary updates -> item['summary']
key = 'text'
summary_index = data.get('summary_index', 0)
if 'summary' not in item:
item['summary'] = []
else:
item['summary'] = list(item['summary'])
summary_list = item['summary']
while len(summary_list) <= summary_index:
summary_list.append({'type': 'summary_text', 'text': ''})
part = summary_list[summary_index].copy()
summary_list[summary_index] = part
target_val = part.get(key, '')
part[key] = deep_merge(target_val, delta)
elif delta_type == 'reasoning_text':
# Reasoning body updates -> item['content']
key = 'text'
content_index = data.get('content_index', 0)
if 'content' not in item:
item['content'] = []
else:
item['content'] = list(item['content'])
content_list = item['content']
while len(content_list) <= content_index:
# Reasoning content parts default to text
content_list.append({'type': 'text', 'text': ''})
part = content_list[content_index].copy()
content_list[content_index] = part
target_val = part.get(key, '')
part[key] = deep_merge(target_val, delta)
elif delta_type in ['text', 'output_text']:
return new_output, None
else:
# Fallback just in case other deltas target reasoning?
pass
else:
# Fallback for other item types
if delta_type in ['text', 'output_text']:
key = 'text'
else:
key = delta_type
current_val = item.get(key)
if current_val is None:
current_val = {} if isinstance(delta, dict) else ''
item[key] = deep_merge(current_val, delta)
return new_output, None
return current_output, None
elif event_type == 'response.output_item.done':
# Delta Event: Output item complete
item = data.get('item')
output_index = data.get('output_index', len(current_output) - 1)
new_output = list(current_output)
if item and 0 <= output_index < len(current_output):
new_output[output_index] = item
elif item:
new_output.append(item)
return new_output, {}
elif event_type.startswith('response.') and event_type.endswith('.done'):
# Delta Events: response.content_part.done, response.text.done, etc.
parts = event_type.split('.')
if len(parts) >= 3:
type_name = parts[1]
# 1. Handle specific Delta "done" signals
if type_name == 'content_part':
# "Signaling that no further changes will occur to a content part"
# If payloads contains the full part, we could update it.
# Usually purely signaling in standard implementation, but we check payload.
part = data.get('part')
output_index = data.get('output_index', len(current_output) - 1)
if part and current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if 'content' in item:
item['content'] = list(item['content'])
content_index = data.get('content_index', len(item['content']) - 1)
if 0 <= content_index < len(item['content']):
item['content'][content_index] = part
return new_output, {}
return current_output, None
elif type_name == 'reasoning_summary_part':
part = data.get('part')
output_index = data.get('output_index', len(current_output) - 1)
if part and current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if 'summary' in item:
item['summary'] = list(item['summary'])
summary_index = data.get('summary_index', len(item['summary']) - 1)
if 0 <= summary_index < len(item['summary']):
item['summary'][summary_index] = part
return new_output, {}
return current_output, None
# 2. Generic Field Done (text.done, audio.done)
if type_name not in ['completed', 'failed']:
output_index = data.get('output_index', len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
key = (
'text'
if type_name
in [
'text',
'output_text',
'reasoning_text',
'reasoning_summary_text',
]
else type_name
)
if type_name == 'function_call_arguments':
key = 'arguments'
if key in data:
final_value = data[key]
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
item_type = item.get('type', '')
if type_name == 'function_call_arguments':
if item_type == 'function_call':
item['arguments'] = final_value
elif item_type == 'message':
content_index = data.get('content_index', 0)
if 'content' in item:
item['content'] = list(item['content'])
if len(item['content']) > content_index:
part = item['content'][content_index].copy()
item['content'][content_index] = part
part[key] = final_value
elif item_type == 'reasoning':
item['status'] = 'completed'
else:
item[key] = final_value
return new_output, {}
return current_output, None
elif event_type == 'response.completed':
# State Machine Event: Completed
response_data = data.get('response', {})
final_output = response_data.get('output')
# Some providers send an empty output on response.completed despite having streamed items
new_output = final_output if final_output else current_output
# Ensure reasoning items are marked as completed in the final output
if new_output:
for item in new_output:
if item.get('type') == 'reasoning' and item.get('status') != 'completed':
item['status'] = 'completed'
return new_output, {
'usage': response_data.get('usage'),
'done': True,
'response_id': response_data.get('id'),
}
elif event_type == 'response.in_progress':
# State Machine Event: In Progress
# We could extract metadata if needed, but for now just acknowledge iteration
return current_output, None
elif event_type == 'response.failed':
# State Machine Event: Failed
error = data.get('response', {}).get('error', {})
return current_output, {'error': error}
elif event_type == 'error':
return current_output, {'error': data}
else:
return current_output, None
def get_source_context(sources: list, source_ids: dict = None, include_content: bool = True) -> str:
"""
Build <source> tag context string from citation sources.
@ -2439,6 +2059,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Keep the template before apply_params_to_form_data consumes the model parameters.
model_system_prompt_template = (form_data.get('params') or {}).get('system')
payload_tools = form_data.get('tools', None) # snapshot before filters
form_data = apply_params_to_form_data(form_data, model)
log.debug('form_data: %s', form_data)
@ -2450,7 +2071,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
chat_id = metadata.get('chat_id')
user_message_id = metadata.get('user_message_id')
pending_assistant_message = None
payload_tools = form_data.get('tools', None) # snapshot before filters
chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
is_note_chat = bool(chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note')
use_builtin_tools = is_note_chat or (
@ -2833,18 +2453,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Otherwise, save any tools that filter inlets added for merging later.
inlet_filter_tools = None if payload_tools is not None else form_data.get('tools', None)
# Mentioned skills get bounded content; selected/default skills can be loaded through view_skill.
# Selected and mentioned skills get bounded content; other skills remain available through view_skill.
chat_context = metadata.get('chat_context') or {}
metadata['chat_context'] = chat_context
skill_versions = chat_context.setdefault('skill_versions', {})
mentioned_skill_ids = set(chat_context.get('mentioned_skill_ids') or []) | extract_skill_ids_from_messages(
form_data.get('messages', [])
)
skill_ids = sorted(
set(form_data.pop('skill_ids', None) or [])
| set(model.get('info', {}).get('meta', {}).get('skillIds', []))
| mentioned_skill_ids
)
activated_skill_ids = set(form_data.pop('skill_ids', None) or []) | mentioned_skill_ids
skill_ids = sorted(activated_skill_ids | set(model.get('info', {}).get('meta', {}).get('skillIds', [])))
available_skills = []
terminal_skills = []
if is_note_chat:
@ -2899,7 +2516,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
skill_manifest = ''
for skill in available_skills:
if skill.id in mentioned_skill_ids or not use_builtin_tools or not model_builtin_tools.get('skills', True):
if skill.id in activated_skill_ids or not use_builtin_tools or not model_builtin_tools.get('skills', True):
from open_webui.models.skills import get_skill_snapshot
version_id = skill_versions.get(skill.id) or skill.version_id
@ -2911,7 +2528,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
continue
skill_versions[skill.id] = version_id
skill_tools_enabled = use_builtin_tools and model_builtin_tools.get('skills', True)
overview = skill_overview(
overview = await skill_overview(
snapshot['content'],
[f['path'] for f in snapshot['data']['files'] if f['path'] != 'SKILL.md'],
tools_enabled=skill_tools_enabled,
@ -2954,7 +2571,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for skill in terminal_skills:
sid = skill['id']
if sid in mentioned_skill_ids or not use_builtin_tools or not model_builtin_tools.get('skills', True):
if sid in activated_skill_ids or not use_builtin_tools or not model_builtin_tools.get('skills', True):
skill_name = unquote(sid.removeprefix(terminal_skill_prefix))
loaded = await get_terminal_skill(
request,
@ -4898,6 +4515,40 @@ async def streaming_chat_response_handler(response, ctx):
]
return prior_output + output if prior_output else output
stream_size = 0
snapshot_size = 0
async def save_current_response_stream():
nonlocal stream_size, snapshot_size
stream_size = snapshot_size = await save_response_stream(
request.app.state.redis,
response_stream_task_id,
chat_id,
metadata.get('message_id'),
full_output(),
)
async def save_response_delta(delta):
nonlocal stream_size
if not stream_size or not request.app.state.redis:
return
# Continue merges items; code blocks write to `code`, not the event's text field.
if (
continuing
or not isinstance(delta.get('delta'), str)
or any(item.get('type') == 'open_webui:code_interpreter' for item in output)
):
stream_size = 0
return
line = dumps_bytes(delta) + b'\n'
if stream_size + len(line) <= snapshot_size * 2 and await append_response_stream(
request.app.state.redis, response_stream_task_id, line, stream_size
):
stream_size += len(line)
else:
# Snapshot after the whole provider chunk, which may update several items.
stream_size = 0
def get_message_error_content(error):
if isinstance(error, HTTPException):
error = error.detail
@ -4978,6 +4629,7 @@ async def streaming_chat_response_handler(response, ctx):
nonlocal output
nonlocal prior_output
nonlocal last_response_id
nonlocal stream_size
response_tool_calls = []
response_usage = {}
@ -4991,45 +4643,6 @@ async def streaming_chat_response_handler(response, ctx):
last_delta_type = None
last_delta_key = None
joined_content = ''
joined_part_count = 0
async def save_current_response_stream(stream_output: list | None = None):
nonlocal joined_content
nonlocal joined_part_count
if not chat_id or not metadata.get('message_id'):
return
# content_parts is append-only, so its length tells us when the join is stale
if joined_part_count != len(content_parts):
joined_content = ''.join(content_parts)
joined_part_count = len(content_parts)
current_stream_output = stream_output if stream_output is not None else full_output()
await save_response_stream(
request.app.state.redis,
response_stream_task_id,
chat_id,
metadata.get('message_id'),
get_output_text(current_stream_output)
if continuing
else joined_content or get_output_text(current_stream_output),
current_stream_output,
)
def get_response_delta_key(delta_data: dict):
event_type = delta_data.get('type', '')
if not event_type.startswith('response.') or not event_type.endswith('.delta'):
return None
return (
event_type,
delta_data.get('item_id'),
delta_data.get('output_index'),
delta_data.get('content_index'),
delta_data.get('summary_index'),
)
def get_response_data_with_full_output_index(response_data: dict):
if prior_output and isinstance(response_data.get('output_index'), int):
return {
@ -5061,7 +4674,6 @@ async def streaming_chat_response_handler(response, ctx):
else last_delta_data,
}
)
await save_current_response_stream()
delta_count = 0
last_delta_data = None
last_delta_type = None
@ -5074,6 +4686,7 @@ async def streaming_chat_response_handler(response, ctx):
nonlocal last_delta_key
delta_data = get_response_data_with_full_output_index(delta_data)
await save_response_delta(delta_data)
delta_key = get_response_delta_key(delta_data)
if (
last_delta_data
@ -5095,7 +4708,8 @@ async def streaming_chat_response_handler(response, ctx):
if delta_count >= delta_chunk_size:
await flush_pending_delta_data(delta_chunk_size)
async def emit_response_completion_event(response_data: dict, stream_output: list | None = None):
async def emit_response_completion_event(response_data: dict):
nonlocal stream_size
if response_data.get('type', '').endswith('.delta'):
await queue_pending_delta_data(
response_data,
@ -5113,7 +4727,7 @@ async def streaming_chat_response_handler(response, ctx):
else get_response_completion_event_data(response_data),
}
)
await save_current_response_stream(stream_output)
stream_size = 0
filter_extra_params = {'__body__': form_data, **extra_params} if filter_functions else None
@ -5433,6 +5047,8 @@ async def streaming_chat_response_handler(response, ctx):
func = tc.get('function', {})
if call_id in output_by_call_id:
output_index, item = output_by_call_id[call_id]
if func.get('name', item.get('name', '')) != item.get('name'):
stream_size = 0
item['name'] = func.get('name', item.get('name', ''))
item['arguments'] = func.get('arguments', item.get('arguments', ''))
item['status'] = 'in_progress'
@ -5484,7 +5100,6 @@ async def streaming_chat_response_handler(response, ctx):
}
)
await save_current_response_stream()
data = None
delta_type = 'tool_call'
@ -5625,7 +5240,7 @@ async def streaming_chat_response_handler(response, ctx):
await event_emitter(
{'type': 'chat:completion', 'data': {'output': full_output()}}
)
await save_current_response_stream()
stream_size = 0
data = None
if value:
@ -5813,7 +5428,7 @@ async def streaming_chat_response_handler(response, ctx):
await event_emitter(
{'type': 'chat:completion', 'data': {'output': full_output()}}
)
await save_current_response_stream()
stream_size = 0
data = None
if delta and data:
@ -5833,7 +5448,11 @@ async def streaming_chat_response_handler(response, ctx):
pass
else:
log.debug('Error: %s', e)
stream_size = 0
continue
finally:
if not stream_size or not request.app.state.redis:
await save_current_response_stream()
await flush_pending_delta_data()
if output:
@ -5923,6 +5542,7 @@ async def streaming_chat_response_handler(response, ctx):
if responses_api_tool_calls:
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
await save_current_response_stream()
return normalize_usage(response_usage)
output_start = len(prior_output)
@ -5955,6 +5575,7 @@ async def streaming_chat_response_handler(response, ctx):
base_system_prompt = get_content_from_message(system_message) if system_message else None
async def emit_output():
await save_current_response_stream()
# Channels publish whole messages; Continue can merge into the preceding item.
snapshot = continuing or (metadata.get('chat_id') or '').startswith('channel:')
frontend_output = []
@ -6337,9 +5958,26 @@ async def streaming_chat_response_handler(response, ctx):
)
try:
messages, context_summary, compacted = await compact_messages_for_request(
request, user, messages, metadata, model_id, request.app.state.MODELS
)
user_messages = [
(message, message.get('content'))
for message in messages
if user_prompt
and message.get('role') == 'user'
and message.get('content') == user_prompt.get('content')
]
try:
# Summarize the user's text, then restore source context for the model.
for message, _ in user_messages:
message['content'] = copy.deepcopy(message.get('content'))
set_last_user_message_content(
metadata.get('base_user_prompt') or user_message, [message]
)
messages, context_summary, compacted = await compact_messages_for_request(
request, user, messages, metadata, model_id, request.app.state.MODELS
)
finally:
for message, content in user_messages:
message['content'] = content
if compacted:
if user_prompt is not None:
messages = [message for message in messages if message != user_prompt]

View file

@ -1186,8 +1186,14 @@ class OAuthClientManager:
if should_send_oauth_resource(client_info):
refresh_data['resource'] = client_info.resource
client_auth = None
if hasattr(client, 'client_secret') and client.client_secret:
refresh_data['client_secret'] = client.client_secret
if client.client_kwargs.get('token_endpoint_auth_method') == 'client_secret_basic':
client_auth = aiohttp.BasicAuth(client.client_id, client.client_secret)
# Some providers reject a body client_id next to Basic as a second credential (#19415)
del refresh_data['client_id']
else:
refresh_data['client_secret'] = client.client_secret
# Add scope if available in client kwargs (some providers require it on refresh)
if (
@ -1202,6 +1208,7 @@ class OAuthClientManager:
async with session_http.post(
token_endpoint,
data=refresh_data,
auth=client_auth,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
@ -1538,9 +1545,15 @@ class OAuthManager:
'refresh_token': token_data['refresh_token'],
'client_id': client.client_id,
}
client_auth = None
# Add client_secret if available (some providers require it)
if hasattr(client, 'client_secret') and client.client_secret:
refresh_data['client_secret'] = client.client_secret
if client.client_kwargs.get('token_endpoint_auth_method') == 'client_secret_basic':
client_auth = aiohttp.BasicAuth(client.client_id, client.client_secret)
if not OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID:
del refresh_data['client_id']
else:
refresh_data['client_secret'] = client.client_secret
# Add scope if available in client kwargs (some providers require it on refresh)
if (
@ -1555,6 +1568,7 @@ class OAuthManager:
async with session_http.post(
token_endpoint,
data=refresh_data,
auth=client_auth,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:

View file

@ -342,3 +342,401 @@ def convert_embedding_response_ollama_to_openai(response) -> dict:
# Fallback: return as is if unrecognized
return response
def get_response_delta_key(delta_data: dict):
event_type = delta_data.get('type', '')
if not event_type.startswith('response.') or not event_type.endswith('.delta'):
return None
return (
event_type,
delta_data.get('item_id'),
delta_data.get('output_index'),
delta_data.get('content_index'),
delta_data.get('summary_index'),
)
def deep_merge(target, source):
"""
Merge source into target recursively (returning new structure).
- Dicts: Recursive merge.
- Strings: Concatenation.
- Others: Overwrite.
"""
if isinstance(target, dict) and isinstance(source, dict):
new_target = target.copy()
for k, v in source.items():
if k in new_target:
new_target[k] = deep_merge(new_target[k], v)
else:
new_target[k] = v
return new_target
elif isinstance(target, str) and isinstance(source, str):
return target + source
else:
return source
def handle_responses_streaming_event(
data: dict,
current_output: list,
) -> tuple[list, dict | None]:
"""
Handle Responses API streaming events in a pure functional way.
Args:
data: The event data
current_output: List of output items (treated as immutable)
Returns:
tuple[list, dict | None]: (new_output, metadata)
- new_output: The updated output list.
- metadata: Metadata to emit (e.g. usage), {} if update occurred, None if skip.
"""
# Default: no change
# Note: treating current_output as immutable, but avoiding full deepcopy for perf.
# We will shallow copy only if we need to modify the list structure or items.
event_type = data.get('type', '')
if event_type == 'response.output_item.added':
item = data.get('item', {})
if item:
new_output = list(current_output)
output_index = data.get('output_index', len(new_output))
existing_index = next(
(
idx
for idx, existing in enumerate(new_output)
if (item.get('id') and existing.get('id') == item.get('id'))
or (
item.get('call_id')
and existing.get('type') == item.get('type')
and existing.get('call_id') == item.get('call_id')
)
),
None,
)
if existing_index is not None:
new_output[existing_index] = item
elif 0 <= output_index < len(new_output):
new_output.insert(output_index, item)
else:
new_output.append(item)
return new_output, None
return current_output, None
elif event_type == 'response.content_part.added':
part = data.get('part', {})
output_index = data.get('output_index', len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
# Copy the item to mutate it
item = new_output[output_index].copy()
new_output[output_index] = item
if 'content' not in item:
item['content'] = []
else:
# Copy content list
item['content'] = list(item['content'])
if item.get('type') == 'reasoning':
# Reasoning items should not have content parts
pass
else:
item['content'].append(part)
return new_output, None
return current_output, None
elif event_type == 'response.reasoning_summary_part.added':
part = data.get('part', {})
output_index = data.get('output_index', len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if 'summary' not in item:
item['summary'] = []
else:
item['summary'] = list(item['summary'])
item['summary'].append(part)
return new_output, None
return current_output, None
elif event_type.startswith('response.') and event_type.endswith('.delta'):
# Generic Delta Handling
parts = event_type.split('.')
if len(parts) >= 3:
delta_type = parts[1]
delta = data.get('delta', '')
output_index = data.get('output_index', max(len(current_output) - 1, 0))
# Chat Completions can start an item with a delta, without an added event.
if output_index >= len(current_output):
current_output = list(current_output)
while len(current_output) <= output_index:
current_output.append(
{'type': 'message', 'status': 'in_progress', 'role': 'assistant', 'content': []}
)
current_output[output_index].update(
id=data.get('item_id'),
type='reasoning'
if delta_type.startswith('reasoning')
else ('function_call' if delta_type == 'function_call_arguments' else 'message'),
)
if current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
item_type = item.get('type', '')
# Determine target field and object based on delta_type and item_type
if delta_type == 'function_call_arguments':
key = 'arguments'
if item_type == 'function_call':
# Function call args are usually strings
item[key] = item.get(key, '') + str(delta)
else:
# Generic handling, refined by item type below
pass
if item_type == 'message':
# Message items: "text"/"output_text" -> "text"
# "reasoning_text" -> Skipped (should use reasoning item)
if delta_type in ['text', 'output_text']:
key = 'text'
elif delta_type in ['reasoning_text', 'reasoning_summary_text']:
# Skip reasoning updates for message items
return new_output, None
else:
key = delta_type
content_index = data.get('content_index', 0)
if 'content' not in item:
item['content'] = []
else:
item['content'] = list(item['content'])
content_list = item['content']
while len(content_list) <= content_index:
content_list.append({'type': 'text', 'text': ''})
# Copy the part to mutate it
part = content_list[content_index].copy()
content_list[content_index] = part
current_val = part.get(key)
if current_val is None:
# Initialize based on delta type
current_val = {} if isinstance(delta, dict) else ''
part[key] = deep_merge(current_val, delta)
elif item_type == 'reasoning':
# Reasoning items: "reasoning_text"/"reasoning_summary_text" -> "text"
# "text"/"output_text" -> Skipped (should use message item)
if delta_type == 'reasoning_summary_text':
# Summary updates -> item['summary']
key = 'text'
summary_index = data.get('summary_index', 0)
if 'summary' not in item:
item['summary'] = []
else:
item['summary'] = list(item['summary'])
summary_list = item['summary']
while len(summary_list) <= summary_index:
summary_list.append({'type': 'summary_text', 'text': ''})
part = summary_list[summary_index].copy()
summary_list[summary_index] = part
target_val = part.get(key, '')
part[key] = deep_merge(target_val, delta)
elif delta_type == 'reasoning_text':
# Reasoning body updates -> item['content']
key = 'text'
content_index = data.get('content_index', 0)
if 'content' not in item:
item['content'] = []
else:
item['content'] = list(item['content'])
content_list = item['content']
while len(content_list) <= content_index:
# Reasoning content parts default to text
content_list.append({'type': 'text', 'text': ''})
part = content_list[content_index].copy()
content_list[content_index] = part
target_val = part.get(key, '')
part[key] = deep_merge(target_val, delta)
elif delta_type in ['text', 'output_text']:
return new_output, None
else:
# Fallback just in case other deltas target reasoning?
pass
else:
# Fallback for other item types
if delta_type in ['text', 'output_text']:
key = 'text'
else:
key = delta_type
current_val = item.get(key)
if current_val is None:
current_val = {} if isinstance(delta, dict) else ''
item[key] = deep_merge(current_val, delta)
return new_output, None
return current_output, None
elif event_type == 'response.output_item.done':
# Delta Event: Output item complete
item = data.get('item')
output_index = data.get('output_index', len(current_output) - 1)
new_output = list(current_output)
if item and 0 <= output_index < len(current_output):
new_output[output_index] = item
elif item:
new_output.append(item)
return new_output, {}
elif event_type.startswith('response.') and event_type.endswith('.done'):
# Delta Events: response.content_part.done, response.text.done, etc.
parts = event_type.split('.')
if len(parts) >= 3:
type_name = parts[1]
# 1. Handle specific Delta "done" signals
if type_name == 'content_part':
# "Signaling that no further changes will occur to a content part"
# If payloads contains the full part, we could update it.
# Usually purely signaling in standard implementation, but we check payload.
part = data.get('part')
output_index = data.get('output_index', len(current_output) - 1)
if part and current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if 'content' in item:
item['content'] = list(item['content'])
content_index = data.get('content_index', len(item['content']) - 1)
if 0 <= content_index < len(item['content']):
item['content'][content_index] = part
return new_output, {}
return current_output, None
elif type_name == 'reasoning_summary_part':
part = data.get('part')
output_index = data.get('output_index', len(current_output) - 1)
if part and current_output and 0 <= output_index < len(current_output):
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
if 'summary' in item:
item['summary'] = list(item['summary'])
summary_index = data.get('summary_index', len(item['summary']) - 1)
if 0 <= summary_index < len(item['summary']):
item['summary'][summary_index] = part
return new_output, {}
return current_output, None
# 2. Generic Field Done (text.done, audio.done)
if type_name not in ['completed', 'failed']:
output_index = data.get('output_index', len(current_output) - 1)
if current_output and 0 <= output_index < len(current_output):
key = (
'text'
if type_name
in [
'text',
'output_text',
'reasoning_text',
'reasoning_summary_text',
]
else type_name
)
if type_name == 'function_call_arguments':
key = 'arguments'
if key in data:
final_value = data[key]
new_output = list(current_output)
item = new_output[output_index].copy()
new_output[output_index] = item
item_type = item.get('type', '')
if type_name == 'function_call_arguments':
if item_type == 'function_call':
item['arguments'] = final_value
elif item_type == 'message':
content_index = data.get('content_index', 0)
if 'content' in item:
item['content'] = list(item['content'])
if len(item['content']) > content_index:
part = item['content'][content_index].copy()
item['content'][content_index] = part
part[key] = final_value
elif item_type == 'reasoning':
item['status'] = 'completed'
else:
item[key] = final_value
return new_output, {}
return current_output, None
elif event_type == 'response.completed':
# State Machine Event: Completed
response_data = data.get('response', {})
final_output = response_data.get('output')
# Some providers send an empty output on response.completed despite having streamed items
new_output = final_output if final_output else current_output
# Ensure reasoning items are marked as completed in the final output
if new_output:
for item in new_output:
if item.get('type') == 'reasoning' and item.get('status') != 'completed':
item['status'] = 'completed'
return new_output, {
'usage': response_data.get('usage'),
'done': True,
'response_id': response_data.get('id'),
}
elif event_type == 'response.in_progress':
# State Machine Event: In Progress
# We could extract metadata if needed, but for now just acknowledge iteration
return current_output, None
elif event_type == 'response.failed':
# State Machine Event: Failed
error = data.get('response', {}).get('error', {})
return current_output, {'error': error}
elif event_type == 'error':
return current_output, {'error': data}
else:
return current_output, None

View file

@ -31,9 +31,15 @@ SKILL_GREP_EXCERPT_MAX_CHARS = 1_200
SKILL_GREP_LOCATIONS_MAX_CHARS = 2_000
def skill_content_page(content: str, offset: int = 0, max_chars: int = SKILL_CONTENT_MAX_CHARS) -> dict:
async def skill_content_page(
content: str, offset: int = 0, max_chars: int = SKILL_CONTENT_MAX_CHARS, *, limit: int | None = None
) -> dict:
if limit is None:
from open_webui.models.config import Config
limit = await Config.get('skills.content_max_chars') or SKILL_CONTENT_MAX_CHARS
offset = max(0, offset)
end = offset + min(SKILL_CONTENT_MAX_CHARS, max(1, max_chars))
end = offset + min(limit, max(1, max_chars))
return {'content': content[offset:end], 'next_offset': end if end < len(content) else None}
@ -196,16 +202,21 @@ def grep_skill_content(files: list[dict], pattern: str, case_insensitive: bool =
return serialized
def skill_overview(content: str, files: list, field: str = 'files', tools_enabled: bool = True) -> dict:
async def skill_overview(content: str, files: list, field: str = 'files', tools_enabled: bool = True) -> dict:
if not tools_enabled:
return {
'content': content[:SKILL_INJECTION_MAX_CHARS],
'next_offset': SKILL_INJECTION_MAX_CHARS if len(content) > SKILL_INJECTION_MAX_CHARS else None,
**bounded_skill_manifest(files, field),
}
large = len(content) > SKILL_CONTENT_MAX_CHARS
from open_webui.models.config import Config
limits = await Config.get_many('skills.content_max_chars', 'skills.preview_max_chars')
content_limit = limits.get('skills.content_max_chars') or SKILL_CONTENT_MAX_CHARS
preview_limit = limits.get('skills.preview_max_chars') or SKILL_PREVIEW_MAX_CHARS
large = len(content) > content_limit
return {
**skill_content_page(content, max_chars=SKILL_PREVIEW_MAX_CHARS if large else SKILL_CONTENT_MAX_CHARS),
**await skill_content_page(content, max_chars=preview_limit if large else content_limit, limit=content_limit),
**bounded_skill_manifest(files, field, SKILL_OVERVIEW_LIST_MAX_CHARS),
}

View file

@ -287,10 +287,10 @@ async def get_terminal_skill(
'description': skill.get('description'),
'directory': directory,
**(
skill_overview(skill['content'], resources, 'resources', tools_enabled)
await skill_overview(skill['content'], resources, 'resources', tools_enabled)
if overview
else {
**skill_content_page(skill['content'], offset, max_chars),
**await skill_content_page(skill['content'], offset, max_chars),
**bounded_skill_manifest(resources, 'resources'),
}
),

View file

@ -1325,7 +1325,13 @@ async def get_tool_servers(request: Request):
data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')
if data is not None:
tool_servers = JSONCodec.loads(data)
request.app.state.TOOL_SERVERS = tool_servers
if tool_servers or not any(
connection.get('config', {}).get('enable') and connection.get('type', 'openapi') == 'openapi'
for connection in await Config.get('tool_server.connections', []) or []
):
request.app.state.TOOL_SERVERS = tool_servers
else:
tool_servers = None
except Exception as e:
log.error(f'Error fetching tool_servers from Redis: {e}')

1
crypto/encryption/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target/

2026
crypto/encryption/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
[package]
name = "encryption"
version = "0.1.0"
edition = "2021"
publish = false
license = "MIT"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openmls = { version = "=0.9.1", features = ["js"] }
openmls_traits = "=0.6.0"
openmls_rust_crypto = "=0.6.0"
openmls_basic_credential = "=0.6.0"
getrandom = { version = "0.2", features = ["js"] }
wasm-bindgen = "=0.2.126"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tls_codec = "0.5"
base64 = "0.22"
sha2 = "0.10"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"

View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.95.0"
targets = ["wasm32-unknown-unknown"]
profile = "minimal"

View file

@ -0,0 +1,267 @@
//! The browser worker is the only owner. No persistence, export, or logging API.
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use openmls::prelude::*;
use openmls_basic_credential::SignatureKeyPair;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::OpenMlsProvider;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tls_codec::{Deserialize as TlsDeserialize, Serialize as TlsSerialize};
use wasm_bindgen::prelude::*;
const SUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
const MAX_PACKET: usize = 1024 * 1024;
fn err<T>(_: T) -> String { "Invalid session operation".into() }
fn require(ok: bool) -> Result<(), String> { if ok { Ok(()) } else { Err(err(())) } }
fn encode<T: TlsSerialize>(v: &T) -> Result<String, String> { Ok(B64.encode(v.tls_serialize_detached().map_err(err)?)) }
fn decode(v: &str) -> Result<Vec<u8>, String> {
require(v.len() <= MAX_PACKET * 4 / 3 + 4)?;
let bytes = B64.decode(v).map_err(err)?;
require(bytes.len() <= MAX_PACKET)?;
Ok(bytes)
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Identity { channel: String, session: String, user: String, participation: String }
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct Binding { identity: Identity, key: String }
fn identity(cred: &Credential) -> Result<Identity, String> {
let basic = BasicCredential::try_from(cred.clone()).map_err(err)?;
serde_json::from_slice(basic.identity()).map_err(err)
}
fn check(cred: &Credential, key: &[u8], context: &Identity, roster: &[Binding]) -> Result<Identity, String> {
let id = identity(cred)?;
require(id.channel == context.channel && id.session == context.session)?;
require(roster.iter().any(|b| b.identity == id && b.key == B64.encode(key)))?;
Ok(id)
}
fn roster(value: &str, context: &Identity) -> Result<Vec<Binding>, String> {
let roster: Vec<Binding> = serde_json::from_str(value).map_err(err)?;
require(!roster.is_empty() && roster.len() <= 128)?;
let mut users = std::collections::HashSet::new();
let mut participations = std::collections::HashSet::new();
for b in &roster {
require(b.identity.channel == context.channel && b.identity.session == context.session
&& users.insert(&b.identity.user) && participations.insert(&b.identity.participation)
&& decode(&b.key)?.len() == 32)?;
}
Ok(roster)
}
#[wasm_bindgen]
pub struct Session {
provider: OpenMlsRustCrypto,
signer: SignatureKeyPair,
credential: CredentialWithKey,
id: Identity,
host: String,
group: Option<MlsGroup>,
}
#[wasm_bindgen]
impl Session {
#[wasm_bindgen(constructor)]
pub fn new(identity_json: &str, host: &str) -> Result<Session, String> {
let id: Identity = serde_json::from_str(identity_json).map_err(err)?;
require([&id.channel, &id.session, &id.user, &id.participation].iter()
.all(|s| !s.is_empty() && s.len() <= 128) && !host.is_empty())?;
let provider = OpenMlsRustCrypto::default();
let signer = SignatureKeyPair::new(SignatureScheme::ED25519).map_err(err)?;
signer.store(provider.storage()).map_err(err)?;
let credential = CredentialWithKey {
credential: BasicCredential::new(serde_json::to_vec(&id).map_err(err)?).into(),
signature_key: signer.public().into(),
};
Ok(Self { provider, signer, credential, id, host: host.into(), group: None })
}
pub fn public_key(&self) -> String { B64.encode(self.signer.public()) }
pub fn key_package(&self) -> Result<String, String> {
require(self.group.is_none())?;
let kp = KeyPackage::builder().build(SUITE, &self.provider, &self.signer, self.credential.clone()).map_err(err)?;
encode(kp.key_package())
}
pub fn create(&mut self) -> Result<(), String> {
require(self.group.is_none() && self.id.participation == self.host)?;
self.group = Some(MlsGroup::builder().ciphersuite(SUITE)
.use_ratchet_tree_extension(true)
.set_past_epoch_deletion_policy(PastEpochDeletionPolicy::MaxEpochs(0))
.with_group_id(GroupId::from_slice(self.id.session.as_bytes()))
.build(&self.provider, &self.signer, self.credential.clone()).map_err(err)?);
Ok(())
}
pub fn join(&mut self, welcome: &str, bindings: &str) -> Result<(), String> {
require(self.group.is_none() && self.id.participation != self.host)?;
let roster = roster(bindings, &self.id)?;
let msg = MlsMessageIn::tls_deserialize_exact(decode(welcome)?).map_err(err)?;
let welcome = match msg.extract() { MlsMessageBodyIn::Welcome(w) => w, _ => return Err(err(())) };
let config = MlsGroupJoinConfig::builder().use_ratchet_tree_extension(true)
.set_past_epoch_deletion_policy(PastEpochDeletionPolicy::MaxEpochs(0)).build();
let group = StagedWelcome::new_from_welcome(&self.provider, &config, welcome, None).map_err(err)?
.into_group(&self.provider).map_err(err)?;
require(group.group_id().as_slice() == self.id.session.as_bytes() && group.ciphersuite() == SUITE)?;
let mut users = std::collections::HashSet::new();
let mut found_host = false;
for m in group.members() {
let id = check(&m.credential, &m.signature_key, &self.id, &roster)?;
require(users.insert(id.user))?;
found_host |= id.participation == self.host;
}
require(found_host)?;
self.group = Some(group);
Ok(())
}
pub fn add(&mut self, key_package: &str, expected: &str, bindings: &str) -> Result<String, String> {
require(self.id.participation == self.host)?;
let roster = roster(bindings, &self.id)?;
let kp = KeyPackageIn::tls_deserialize_exact(decode(key_package)?).map_err(err)?
.validate(self.provider.crypto(), ProtocolVersion::Mls10).map_err(err)?;
let id = check(kp.leaf_node().credential(), kp.leaf_node().signature_key().as_slice(), &self.id, &roster)?;
require(id.participation == expected)?;
let group = self.group.as_mut().ok_or_else(|| err(()))?;
require(group.members().count() < 128 && !group.members().any(|m| identity(&m.credential).is_ok_and(|i| i.user == id.user)))?;
let (commit, welcome, _) = group.add_members(&self.provider, &self.signer, &[kp]).map_err(err)?;
let result = json!({"commit": encode(&commit)?, "welcome": encode(&welcome)?});
group.merge_pending_commit(&self.provider).map_err(err)?;
Ok(result.to_string())
}
pub fn remove(&mut self, participation: &str, bindings: &str) -> Result<String, String> {
require(self.id.participation == self.host && participation != self.host)?;
let roster = roster(bindings, &self.id)?;
require(!roster.iter().any(|b| b.identity.participation == participation))?;
let group = self.group.as_mut().ok_or_else(|| err(()))?;
let index = group.members().find(|m| identity(&m.credential).is_ok_and(|i| i.participation == participation))
.ok_or_else(|| err(()))?.index;
let (commit, _, _) = group.remove_members(&self.provider, &self.signer, &[index]).map_err(err)?;
let out = encode(&commit)?;
group.merge_pending_commit(&self.provider).map_err(err)?;
Ok(out)
}
pub fn update(&mut self) -> Result<String, String> {
let group = self.group.as_mut().ok_or_else(|| err(()))?;
if self.id.participation == self.host {
let bundle = group.self_update(&self.provider, &self.signer, LeafNodeParameters::default()).map_err(err)?;
let out = encode(bundle.commit())?;
group.merge_pending_commit(&self.provider).map_err(err)?;
Ok(out)
} else {
let (proposal, _) = group.propose_self_update(&self.provider, &self.signer, LeafNodeParameters::default()).map_err(err)?;
encode(&proposal)
}
}
pub fn commit_updates(&mut self) -> Result<String, String> {
require(self.id.participation == self.host)?;
let group = self.group.as_mut().ok_or_else(|| err(()))?;
let (commit, _, _) = group.commit_to_pending_proposals(&self.provider, &self.signer).map_err(err)?;
let out = encode(&commit)?;
group.merge_pending_commit(&self.provider).map_err(err)?;
Ok(out)
}
pub fn encrypt(&mut self, text: &str) -> Result<String, String> {
require(text.len() <= 100 * 1024)?; // Includes the authenticated receipt/message envelope.
let group = self.group.as_mut().ok_or_else(|| err(()))?;
encode(&group.create_message(&self.provider, &self.signer, text.as_bytes()).map_err(err)?)
}
pub fn process(&mut self, packet: &str, expected: &str, bindings: &str) -> Result<String, String> {
let roster = roster(bindings, &self.id)?;
let group = self.group.as_mut().ok_or_else(|| err(()))?;
let msg = MlsMessageIn::tls_deserialize_exact(decode(packet)?).map_err(err)?;
let processed = group.process_message(&self.provider, msg.try_into_protocol_message().map_err(err)?).map_err(err)?;
let index = match processed.sender() { Sender::Member(i) => *i, _ => return Err(err(())) };
let member = group.member_at(index).ok_or_else(|| err(()))?;
let sender = check(processed.credential(), &member.signature_key, &self.id, &roster)?;
require(sender.participation == expected)?;
let result = match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(m) => {
let bytes = m.into_bytes();
require(bytes.len() <= 100 * 1024)?;
json!({"kind":"application", "sender":sender, "text":String::from_utf8(bytes).map_err(err)?})
},
ProcessedMessageContent::ProposalMessage(p) => {
match p.proposal() {
Proposal::Update(u) => {
let id = check(u.leaf_node().credential(), u.leaf_node().signature_key().as_slice(), &self.id, &roster)?;
require(id == sender)?;
},
_ => return Err(err(())),
}
group.store_pending_proposal(self.provider.storage(), *p).map_err(err)?;
json!({"kind":"proposal"})
},
ProcessedMessageContent::StagedCommitMessage(c) => {
require(sender.participation == self.host)?;
if let Some(leaf) = c.update_path_leaf_node() {
require(check(leaf.credential(), leaf.signature_key().as_slice(), &self.id, &roster)? == sender)?;
}
let mut added_users = std::collections::HashSet::new();
for p in c.queued_proposals() {
match p.proposal() {
Proposal::Add(a) => {
let leaf = a.key_package().leaf_node();
let id = check(leaf.credential(), leaf.signature_key().as_slice(), &self.id, &roster)?;
require(added_users.insert(id.user.clone()) && !group.members().any(|m| identity(&m.credential).is_ok_and(|i| i.user == id.user)))?;
},
Proposal::Remove(r) => {
let m = group.member_at(r.removed()).ok_or_else(|| err(()))?;
let id = identity(&m.credential)?;
require(id.participation != self.host && !roster.iter().any(|b| b.identity == id))?;
},
Proposal::Update(u) => {
let idx = match p.sender() { Sender::Member(i) => *i, _ => return Err(err(())) };
let old = group.member_at(idx).ok_or_else(|| err(()))?;
require(check(u.leaf_node().credential(), u.leaf_node().signature_key().as_slice(), &self.id, &roster)? == identity(&old.credential)?)?;
},
_ => return Err(err(())),
}
}
require(group.members().count() + c.add_proposals().count() - c.remove_proposals().count() <= 128)?;
group.merge_staged_commit(&self.provider, *c).map_err(err)?;
json!({"kind":"commit"})
},
_ => return Err(err(())),
};
Ok(result.to_string())
}
pub fn members(&self) -> Result<String, String> {
let group = self.group.as_ref().ok_or_else(|| err(()))?;
let mut members = Vec::<Value>::new();
for m in group.members() {
let id = identity(&m.credential)?;
let mut hash = Sha256::new();
hash.update(b"Open WebUI temporary chat identity v1\0");
hash.update(serde_json::to_vec(&id).map_err(err)?);
hash.update(&m.signature_key);
members.push(json!({"identity":id,"fingerprint":format!("{:x}",hash.finalize())}));
}
Ok(json!(members).to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn client(n: usize, session: &str) -> Session {
Session::new(&json!({"channel":"c","session":session,"user":format!("u{n}"),"participation":format!("p{n}")}).to_string(), "p0").unwrap()
}
fn bindings(clients: &[&Session]) -> String {
json!(clients.iter().map(|c| json!({"identity":c.id,"key":c.public_key()})).collect::<Vec<_>>()).to_string()
}
#[test]
fn bindings_membership_and_epochs() {
let mut a = client(0,"s"); let mut b = client(1,"s"); let c = client(2,"other-session");
a.create().unwrap();
let roster = bindings(&[&a,&b]);
assert!(a.add(&c.key_package().unwrap(), "p2", &roster).is_err());
let old = a.encrypt("past epoch").unwrap();
let added: Value = serde_json::from_str(&a.add(&b.key_package().unwrap(), "p1", &roster).unwrap()).unwrap();
b.join(added["welcome"].as_str().unwrap(), &roster).unwrap();
assert!(b.process(&old,"p0",&roster).is_err());
let msg = a.encrypt("hello").unwrap();
assert!(b.process(&msg,"p0",&roster).unwrap().contains("hello"));
assert!(b.process(&msg,"p0",&roster).is_err());
// A valid MLS commit from an ordinary member must still be rejected.
let bundle = b.group.as_mut().unwrap().self_update(&b.provider,&b.signer,LeafNodeParameters::default()).unwrap();
assert!(a.process(&encode(bundle.commit()).unwrap(),"p1",&roster).is_err());
assert!(b.remove("p0", &roster).is_err());
}
}

6
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.12.2",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.12.2",
"version": "0.12.3",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -55,7 +55,7 @@
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"docx-preview": "^0.4.0",
"docx-preview": "0.4.0",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",

View file

@ -1,8 +1,13 @@
{
"name": "open-webui",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"scripts": {
"crypto:verify": "node scripts/encryption/artifact.mjs",
"predev": "npm run crypto:verify",
"predev:5050": "npm run crypto:verify",
"prebuild": "npm run crypto:verify",
"prebuild:watch": "npm run crypto:verify",
"dev": "npm run pyodide:fetch && vite dev --host",
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
"build": "npm run pyodide:fetch && vite build",
@ -100,7 +105,7 @@
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"docx-preview": "^0.4.0",
"docx-preview": "0.4.0",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",

View file

@ -0,0 +1,71 @@
// Normal builds only read committed files. No Rust or network required.
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
const root = new URL('../../', import.meta.url).pathname;
const source = 'crypto/encryption';
const artifact = 'src/lib/encryption/vendor';
const hash = (data) => createHash('sha256').update(data).digest('hex');
function files(dir) {
return readdirSync(join(root, dir), { withFileTypes: true })
.flatMap((e) => {
if (e.name === 'target' || e.name.startsWith('.')) return [];
const path = join(dir, e.name);
return e.isDirectory() ? files(path) : [path];
})
.sort();
}
const inputs = [
...files(source),
'scripts/encryption/build.py',
'scripts/encryption/artifact.mjs'
].sort();
const sourceFingerprint = hash(
inputs.map((p) => `${p}\0${hash(readFileSync(join(root, p)))}`).join('\n')
);
const manifestPath = join(root, artifact, 'manifest.json');
if (process.argv.includes('--write')) {
const metadata = JSON.parse(
readFileSync(process.argv[process.argv.indexOf('--metadata') + 1], 'utf8')
);
const checksums = Object.fromEntries(
files(artifact)
.filter((p) => !p.endsWith('/manifest.json'))
.map((p) => [relative(artifact, p), hash(readFileSync(join(root, p)))])
);
writeFileSync(
manifestPath,
JSON.stringify(
{
format: 1,
sourceFingerprint,
sourceRevision: process.env.CRYPTO_SOURCE_REVISION,
toolchain: '1.95.0',
wasmBindgen: '0.2.126',
dependencies: metadata.packages.map(({ name, version, license }) => ({
name,
version,
license
})),
checksums
},
null,
2
) + '\n'
);
} else {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (manifest.sourceFingerprint !== sourceFingerprint)
throw Error('Encryption source changed: rebuild and review the artifact.');
const actual = files(artifact)
.filter((p) => !p.endsWith('/manifest.json'))
.map((p) => relative(artifact, p))
.sort();
if (JSON.stringify(actual) !== JSON.stringify(Object.keys(manifest.checksums).sort()))
throw Error('Unexpected crypto artifact files');
for (const [p, digest] of Object.entries(manifest.checksums)) {
if (hash(readFileSync(join(root, artifact, p))) !== digest)
throw Error(`Crypto artifact checksum mismatch: ${p}`);
}
console.log('Encryption artifact source and checksums verified');
}

138
scripts/encryption/build.py Normal file
View file

@ -0,0 +1,138 @@
"""Manual artifact release only; never invoked by the application build."""
import json
import os
import subprocess
import tempfile
from functools import cache
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / 'crypto/encryption'
OUTPUT = ROOT / 'src/lib/encryption/vendor'
def run(*args, **kwargs):
return subprocess.run(args, cwd=SOURCE, check=True, **kwargs)
run('cargo', 'test', '--locked')
run('cargo', 'build', '--locked', '--release', '--target', 'wasm32-unknown-unknown')
run(
'wasm-bindgen',
'--target',
'web',
'--out-name',
'encryption',
'--out-dir',
str(OUTPUT),
str(SOURCE / 'target/wasm32-unknown-unknown/release/encryption.wasm'),
)
# Use Cargo's actual target build graph, excluding tests and unused optional crates.
metadata = json.loads(run('cargo', 'metadata', '--locked', '--format-version', '1', capture_output=True).stdout)
tree = run(
'cargo',
'tree',
'--locked',
'--target',
'wasm32-unknown-unknown',
'--edges',
'normal,build',
'--prefix',
'none',
'--format',
'{p}',
capture_output=True,
).stdout.decode()
used = {tuple(line.split()[:2]) for line in tree.splitlines()}
metadata['packages'] = [p for p in metadata['packages'] if (p['name'], 'v' + p['version']) in used]
assert {(p['name'], 'v' + p['version']) for p in metadata['packages']} == used
@cache
def download(url):
with urlopen(url, timeout=30) as response:
return response.read().decode('utf-8')
texts = {}
notices = [
'Third-Party Licenses',
'===================',
'',
'Scope: Encryption WebAssembly artifact and its build-time dependencies.',
'Packages are listed alphabetically with references to the full license and notice texts below.',
'Identical texts are included once; package-specific copyright notices are preserved.',
'Source code, including MPL-2.0 dependencies, is available at each Source URL below.',
]
for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])):
if package['source'] is None:
continue # The local wrapper is not a third-party dependency.
license = package['license'] or ''
choices = {
'MIT',
'Apache-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'ISC',
'Unicode-3.0',
'Zlib',
'CC0-1.0',
'Unlicense',
'MPL-2.0',
}
if not any(x.strip(' ()') in choices for x in license.replace('/', ' OR ').split(' OR ')):
raise RuntimeError(f'Review dependency license: {package["name"]} {license}')
directory = Path(package['manifest_path']).parent
documents = {
path.name: path.read_text()
for path in sorted(directory.iterdir())
if path.is_file() and path.name.upper().startswith(('LICENSE', 'COPYING', 'NOTICE'))
}
if package.get('license_file'):
path = directory / package['license_file']
documents[path.name] = path.read_text()
if not documents:
# Some published crates omit the workspace license; use their exact source revision.
if license == 'MPL-2.0':
url = 'https://www.mozilla.org/media/MPL/2.0/index.txt'
expected = 'Mozilla Public License Version 2.0'
else:
repository = urlparse(package['repository'])
if repository.netloc != 'github.com' or license not in ('MIT', 'Apache-2.0'):
raise RuntimeError(f'Missing license notice: {package["name"]}')
repo = '/'.join(repository.path.strip('/').split('/')[:2])
revision = json.loads((directory / '.cargo_vcs_info.json').read_text())['git']['sha1']
url = f'https://raw.githubusercontent.com/{repo}/{revision}/LICENSE'
expected = 'Permission is hereby granted' if license == 'MIT' else 'Apache License'
documents[url] = download(url)
if expected not in documents[url]:
raise RuntimeError(f'Unexpected license notice: {url}')
references = []
for name, text in documents.items():
text = text.strip()
if not text:
raise RuntimeError(f'Empty license notice: {package["name"]}/{name}')
number = texts.setdefault(text, len(texts) + 1)
references.append(f'{name}: [{number}]')
notices.append(
f'\nPackage: {package["name"]}\nVersion: {package["version"]}\nLicense: {license}\n'
f'Source: https://crates.io/api/v1/crates/{package["name"]}/{package["version"]}/download\n'
+ '\n'.join(references)
)
notices.extend(
f'\nLicense and notice text [{number}]\n--------------------------------\n{text}' for text, number in texts.items()
)
(OUTPUT / 'THIRD_PARTY_LICENSES.txt').write_text('\n'.join(notices) + '\n')
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as temp:
json.dump(metadata, temp)
temp.flush()
env = dict(
os.environ,
CRYPTO_SOURCE_REVISION=subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT).decode().strip(),
)
subprocess.run(
['node', str(ROOT / 'scripts/encryption/artifact.mjs'), '--write', '--metadata', temp.name], check=True, env=env
)

View file

@ -117,7 +117,19 @@ textarea::-webkit-scrollbar-corner {
}
.markdown-prose {
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:text-[0.8125rem] prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
}
.markdown-prose,
.document-markdown {
@apply prose-blockquote:my-2 prose-blockquote:border-s-2 prose-blockquote:border-gray-200 prose-blockquote:dark:border-gray-700 prose-blockquote:ps-3 prose-blockquote:pe-0 prose-blockquote:leading-relaxed prose-blockquote:not-italic prose-blockquote:font-normal prose-blockquote:text-gray-600 prose-blockquote:dark:text-gray-400 prose-blockquote:[&>:first-child]:mt-0 prose-blockquote:[&>:last-child]:mb-0;
}
.markdown-prose :where(blockquote p:first-of-type)::before,
.markdown-prose :where(blockquote p:last-of-type)::after,
.document-markdown :where(blockquote p:first-of-type)::before,
.document-markdown :where(blockquote p:last-of-type)::after {
content: none;
}
.markdown-prose-sm {
@ -1023,3 +1035,102 @@ html.high-contrast:not(.dark) .shimmer {
color: var(--color-gray-800);
-webkit-text-fill-color: var(--color-gray-800);
}
/* Shared by file previews and the attachment modal. */
.office-preview {
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-gray-800);
background: #fff;
border-radius: 0.25rem;
}
.dark .office-preview {
color: var(--color-gray-200);
background: var(--color-gray-900);
}
.office-preview table {
border-collapse: collapse;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
line-height: 1.3;
}
.office-preview table td,
.office-preview table th {
border: 1px solid var(--color-gray-200);
padding: 0.25rem 0.625rem;
text-align: left;
white-space: nowrap;
user-select: text;
cursor: cell;
max-width: 18.75rem;
overflow: hidden;
text-overflow: ellipsis;
}
.dark .office-preview table td,
.dark .office-preview table th {
border-color: var(--color-gray-800);
}
/* Column letter headers */
.office-preview table th.excel-col-hdr {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-gray-100);
color: var(--color-gray-600);
font-weight: 500;
font-size: 0.65rem;
text-align: center;
padding: 0.1875rem 0.625rem;
border-bottom: 2px solid var(--color-gray-300);
}
.dark .office-preview table th.excel-col-hdr {
background: var(--color-gray-850);
color: var(--color-gray-400);
border-bottom-color: var(--color-gray-700);
}
/* Row number cells */
.office-preview .excel-row-num {
position: sticky;
left: 0;
z-index: 1;
background: var(--color-gray-100);
color: var(--color-gray-600);
font-size: 0.6rem;
text-align: right !important;
padding: 0.25rem 0.5rem 0.25rem 0.25rem !important;
user-select: none;
width: 1px;
white-space: nowrap;
border-right: 2px solid var(--color-gray-300) !important;
}
.dark .office-preview .excel-row-num {
background: var(--color-gray-850);
color: var(--color-gray-400);
border-right-color: var(--color-gray-700) !important;
}
/* Corner cell (intersection of row nums and col headers) */
.office-preview thead .excel-row-num {
z-index: 3;
}
/* Number cells right-aligned */
.office-preview .excel-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Row hover and selection */
.office-preview table tbody tr:nth-child(even) td:not(.excel-row-num) {
background: rgba(0, 0, 0, 0.015);
}
.dark .office-preview table tbody tr:nth-child(even) td:not(.excel-row-num) {
background: rgba(255, 255, 255, 0.02);
}
.office-preview table tbody tr:hover td:not(.excel-row-num) {
background: rgba(59, 130, 246, 0.06);
}
.dark .office-preview table tbody tr:hover td:not(.excel-row-num) {
background: rgba(59, 130, 246, 0.1);
}
.office-preview table td:focus {
outline: 2px solid rgba(59, 130, 246, 0.5);
outline-offset: -2px;
}

View file

@ -479,12 +479,13 @@ export const getChannelThreadMessages = async (
channel_id: string,
message_id: string,
skip: number = 0,
limit: number = 50
limit: number = 50,
anchor: { around?: string; before?: string } = {}
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}`,
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}&${new URLSearchParams(anchor)}`,
{
method: 'GET',
headers: {

View file

@ -0,0 +1,36 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type SearchKind = 'chat' | 'message' | 'note';
export type SearchResult = {
id: string;
kind: SearchKind;
title: string;
context: string;
snippet: string;
preview: string;
url: string;
updated_at: number;
shared: boolean;
pinned: boolean;
};
export type SearchScope = { id: string; name: string; kind: 'folder' | 'channel' };
export type SearchResponse = {
items: SearchResult[];
has_more: boolean;
scopes: SearchScope[];
kinds: SearchKind[];
};
export const searchWorkspace = async (
token: string,
filters: { q: string; kind: string; sharing: string; scope: string; page: number },
signal: AbortSignal
): Promise<SearchResponse> => {
const params = new URLSearchParams({ ...filters, page: String(filters.page) });
const response = await fetch(`${WEBUI_API_BASE_URL}/search?${params}`, {
headers: { Authorization: `Bearer ${token}` },
signal
});
if (!response.ok) throw new Error('Search is unavailable. Please try again.');
return response.json();
};

View file

@ -1,6 +1,36 @@
import { get } from 'svelte/store';
import { config } from '$lib/stores';
import { checkSourceSize, readSourceResponse } from './sourceText';
// The same attachment is used by @mentions, terminal uploads, and Files-pane requests.
export type FilesystemFile = {
type: 'filesystem';
id: string;
path: string;
url: string;
name: string;
status: string;
size?: number;
modified?: number;
terminal_id?: string;
session_id?: string | null;
};
export const filesystemFile = (
entry: { path: string; name: string; size?: number; modified?: number },
terminalId: string,
sessionId: string | null
): FilesystemFile => ({
type: 'filesystem',
id: entry.path,
path: entry.path,
url: entry.path,
name: entry.name,
size: entry.size,
modified: entry.modified,
status: 'processed',
terminal_id: terminalId,
session_id: sessionId
});
export type FileEntry = {
name: string;
type: 'file' | 'directory';
@ -396,12 +426,13 @@ export const downloadFileBlob = async (
baseUrl: string,
apiKey: string,
path: string,
sessionId?: string
sessionId?: string,
signal?: AbortSignal
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`;
const headers: Record<string, string> = bearerHeaders(apiKey);
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
const res = await fetch(url, { headers, signal }).catch(() => null);
if (!res || !res.ok) return null;
@ -415,12 +446,13 @@ export const downloadFilePreview = async (
baseUrl: string,
apiKey: string,
path: string,
sessionId?: string
sessionId?: string,
signal?: AbortSignal
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}&preview=true`;
const headers: Record<string, string> = bearerHeaders(apiKey);
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
const res = await fetch(url, { headers, signal }).catch(() => null);
if (!res) return null;
if (!res.ok) return null;

View file

@ -84,6 +84,8 @@
const cleaned = entriesToI18n(uiI18nEntries);
const res = await updateAdminConfig(localStorage.token, {
...adminConfig,
SKILL_CONTENT_MAX_CHARS: adminConfig.SKILL_CONTENT_MAX_CHARS ?? null,
SKILL_PREVIEW_MAX_CHARS: adminConfig.SKILL_PREVIEW_MAX_CHARS ?? null,
DEFAULT_INTERFACE_SETTINGS: defaultInterfaceSettings,
I18N: cleaned
});
@ -337,6 +339,36 @@
/>
</AdminSettingRow>
{/if}
<AdminSettingField
forId="skill-content-max-chars"
label={$i18n.t('settings.admin.general.skillContentMaxChars.label')}
description={$i18n.t('settings.admin.general.skillContentMaxChars.description')}
>
<input
id="skill-content-max-chars"
class={inputClass}
type="number"
min="1"
step="1"
placeholder={$i18n.t('Default')}
bind:value={adminConfig.SKILL_CONTENT_MAX_CHARS}
/>
</AdminSettingField>
<AdminSettingField
forId="skill-preview-max-chars"
label={$i18n.t('settings.admin.general.skillPreviewMaxChars.label')}
description={$i18n.t('settings.admin.general.skillPreviewMaxChars.description')}
>
<input
id="skill-preview-max-chars"
class={inputClass}
type="number"
min="1"
step="1"
placeholder={$i18n.t('Default')}
bind:value={adminConfig.SKILL_PREVIEW_MAX_CHARS}
/>
</AdminSettingField>
<AdminSettingRow
label={$i18n.t('settings.admin.general.notes.label')}
description={$i18n.t('settings.admin.general.notes.description')}
@ -352,6 +384,14 @@
<Switch bind:state={adminConfig.ENABLE_CHANNELS} ariaLabelledbyId={labelId} />
</AdminSettingRow>
{#if adminConfig.ENABLE_CHANNELS}
<AdminSettingRow
label={$i18n.t('settings.admin.general.encryptedChannels.label')}
description={$i18n.t('settings.admin.general.encryptedChannels.description')}
labelClassName="text-gray-500 dark:text-gray-500"
let:labelId
>
<Switch bind:state={adminConfig.ENABLE_ENCRYPTED_CHANNELS} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('settings.admin.general.modelResponseMode.label')}
description={$i18n.t('settings.admin.general.modelResponseMode.description')}

View file

@ -8,8 +8,8 @@
import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions';
import { config } from '$lib/stores';
export let permissions = {};
export let defaultPermissions = {};
export let permissions: Record<string, any> = {};
export let defaultPermissions: Record<string, any> = {};
// Reactive statement to ensure all fields are present in `permissions`
$: {
@ -1007,6 +1007,25 @@
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-normal">
{$i18n.t('Encrypted Channels')}
</div>
<Switch
bind:state={permissions.features.encrypted_channels}
ariaLabel={$i18n.t('Encrypted Channels')}
/>
</div>
{#if defaultPermissions?.features?.encrypted_channels && !permissions.features.encrypted_channels}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-normal">

View file

@ -5,8 +5,10 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { v4 as uuidv4 } from 'uuid';
import type { Socket } from 'socket.io-client';
import {
config,
chatId,
channels,
channelId as _channelId,
@ -19,6 +21,7 @@
import Messages from './Messages.svelte';
import MessageInput from './MessageInput.svelte';
import Navbar from './Navbar.svelte';
import { canUseEncryption, connected, enter, request } from '$lib/encryption/client';
import Drawer from '../common/Drawer.svelte';
import ResizableSidePanel from '../common/ResizableSidePanel.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
@ -28,6 +31,58 @@
export let id = '';
let mounted = false;
let invitationSession: string | null = null;
let stopInvitation = () => {};
$: watchInvitation(
$socket,
channel?.id === id ? id : null,
mounted && canUseEncryption($user, $config?.features)
);
function watchInvitation(
connection: Socket | null,
channelId: string | null,
enabled: boolean | undefined
) {
stopInvitation();
stopInvitation = () => {};
invitationSession = null;
if (!enabled || !connection || !channelId) return;
let alive = true;
let revision = 0;
const receive = (event: { channel: string; session: string | null }) => {
if (event.channel === channelId) {
revision++;
invitationSession = event.session;
}
};
const clear = () => {
revision++;
invitationSession = null;
};
const query = () => {
if (!connected(connection)) return;
const issued = revision;
void request(connection, 'status', { channel: channelId })
.then((result) => {
if (alive && revision === issued)
invitationSession = result.available ? result.session : null;
})
.catch(() => {});
};
connection.on('encryption:available', receive);
connection.on('disconnect', clear);
connection.on('connect', query);
query();
stopInvitation = () => {
alive = false;
connection.off('encryption:available', receive);
connection.off('disconnect', clear);
connection.off('connect', query);
};
}
let currentId = null;
let scrollEnd = true;
@ -51,12 +106,15 @@
$: if (channel && $page.url.searchParams.get('thread')) {
threadId = $page.url.searchParams.get('thread');
window.history.replaceState(history.state, '', `/channels/${id}`);
if (!$page.url.searchParams.has('message')) {
window.history.replaceState(history.state, '', `/channels/${id}`);
}
}
const scrollToBottom = () => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
// The channel uses flex-col-reverse, whose bottom is scrollTop = 0.
messagesContainerElement.scrollTop = 0;
}
};
@ -147,18 +205,29 @@
if (type === 'message') {
if ((data?.parent_id ?? null) === null) {
const tempId = data?.temp_id ?? null;
messages = [
{ ...data, temp_id: null },
...messages.filter((m) => !tempId || m?.temp_id !== tempId)
];
const idx = (messages ?? []).findIndex(
(message) =>
message.id === data.id || (data.temp_id && message.temp_id === data.temp_id)
);
if (idx === -1) {
messages = [{ ...data, temp_id: null }, ...(messages ?? [])];
} else {
// Confirm the existing row without changing its position or render key.
const previous = messages[idx];
messages[idx] = {
...previous,
...data,
temp_id: null,
client_id: previous.client_id ?? previous.id
};
}
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}
await tick();
if (scrollEnd) {
if (idx === -1 && scrollEnd) {
scrollToBottom();
}
}
@ -166,7 +235,7 @@
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
messages[idx] = { ...messages[idx], ...data };
}
} else if (type === 'message:delete') {
messages = messages
@ -188,12 +257,12 @@
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
messages[idx] = { ...messages[idx], ...data };
}
} else if (type.includes('message:reaction')) {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
messages[idx] = { ...messages[idx], ...data };
}
} else if (type === 'typing' && event.message_id === null) {
if (event.user.id === $user?.id) {
@ -254,6 +323,7 @@
{
...message,
id: tempId,
client_id: tempId,
user_id: $user?.id,
user: $user,
reply_to_message: reply_to_message ?? null,
@ -263,14 +333,13 @@
...messages
];
const res = await sendMessage(localStorage.token, channel_id, message).catch((error) => {
await tick();
if (id === channel_id) scrollToBottom();
await sendMessage(localStorage.token, channel_id, message).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res && id === channel_id && messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
const onChange = async () => {
@ -297,6 +366,7 @@
};
onMount(() => {
mounted = true;
if ($chatId) {
chatId.set('');
}
@ -310,6 +380,7 @@
});
onDestroy(() => {
stopInvitation();
// last read at
updateLastReadAt(id);
_channelId.set(null);
@ -358,7 +429,30 @@
return null;
});
}}
/>
>
<svelte:fragment slot="subtitle">
{#if invitationSession}
<div
class="flex flex-wrap items-center gap-x-1.5 text-[0.6875rem] font-normal text-gray-500 dark:text-gray-400"
>
<span>{$i18n.t('Encrypted chat in progress')}</span>
<span aria-hidden="true">·</span>
<button
type="button"
aria-label={$i18n.t('Join encrypted chat')}
class="text-[0.6875rem] font-normal text-gray-700 underline-offset-2 hover:underline dark:text-gray-200"
on:click={() => {
if (!invitationSession || !canUseEncryption($user, $config?.features)) return;
enter(id, { action: 'join', session: invitationSession });
void goto(`/channels/${encodeURIComponent(id)}/encrypted`);
}}
>
{$i18n.t('Join')}
</button>
</div>
{/if}
</svelte:fragment>
</Navbar>
{#if channel && messages !== null}
<div class="flex-1 overflow-y-auto">

View file

@ -97,8 +97,8 @@
onUpdate={updateMembers}
/>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 mb-1">
<div class="flex max-h-[calc(100dvh-3rem)] flex-col">
<div class="flex shrink-0 justify-between dark:text-gray-100 px-4 pt-3 mb-1">
<div class="self-center text-base">
<div class="flex items-center gap-0.5 shrink-0">
{#if channel?.type === 'dm'}
@ -130,16 +130,18 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<div
class="flex min-h-0 flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200"
>
<div class="flex min-h-0 flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
class="flex min-h-0 flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="flex flex-col w-full h-full pb-2">
<div class="flex min-h-0 flex-col w-full pb-2">
<UserList
{channel}
apps={joinedApps}

View file

@ -106,13 +106,13 @@
}
</script>
<div class="flex flex-col justify-center">
<div class="flex min-h-0 flex-col">
{#if users === null || total === null}
<div class="my-10">
<Spinner className="size-5" />
</div>
{:else}
<div class="flex items-center justify-between px-2 mb-1">
<div class="flex shrink-0 items-center justify-between px-2 mb-1">
<div class="flex gap-1 items-center">
<span class="text-sm">
{$i18n.t('Members')}
@ -136,7 +136,7 @@
<!-- <hr class="my-1 border-gray-100/5- dark:border-gray-850/50" /> -->
{#if search}
<div class="flex gap-1 px-1 mb-1">
<div class="flex shrink-0 gap-1 px-1 mb-1">
<div class=" flex w-full space-x-2">
<div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3">
@ -165,7 +165,9 @@
{/if}
{#if users.length > 0 || filteredApps.length > 0}
<div class="scrollbar-hidden relative whitespace-nowrap w-full max-w-full">
<div
class="min-h-0 overflow-y-auto overscroll-contain relative whitespace-nowrap w-full max-w-full"
>
<div class=" text-sm text-left text-gray-500 dark:text-gray-400 w-full max-w-full">
<!-- <div
class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200 w-full mb-0.5"
@ -325,7 +327,9 @@
</div>
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
<div class="shrink-0">
<Pagination bind:page count={total} perPage={30} />
</div>
{/if}
{:else}
<div class="text-gray-500 text-xs text-center py-5 px-10">

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { channels } from '$lib/stores';
import { channels, config, user } from '$lib/stores';
import { goto } from '$app/navigation';
import { canUseEncryption, enter } from '$lib/encryption/client';
import { updateChannelMemberMuteStatusById } from '$lib/apis/channels';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
@ -11,12 +13,18 @@
import BellSlash from '$lib/components/icons/BellSlash.svelte';
import Settings from '$lib/components/icons/Settings.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
const i18n = getContext<any>('i18n');
// Keep the dynamic menu labels visible to i18next-parser.
// $i18n.t('Conversation options');
// $i18n.t('Channel options');
export let channel: { id: string; type?: string | null; is_channel_muted?: boolean };
export let channel: {
id: string;
type?: string | null;
is_channel_muted?: boolean;
is_private?: boolean | null;
};
export let sidebar = false;
export let onEdit: (() => void) | undefined = undefined;
export let onHide: (() => Promise<void>) | undefined = undefined;
@ -65,6 +73,22 @@
</button>
<div slot="content">
<DropdownMenu className="w-44 border-gray-200/30! dark:border-gray-700/30!">
{#if canUseEncryption($user, $config?.features) && (channel.type === 'dm' || (channel.type === 'group' && channel.is_private === true))}
<button
type="button"
role="menuitem"
on:click={() => {
if (!canUseEncryption($user, $config?.features)) return;
show = false;
enter(channel.id, { action: 'start' });
void goto(`/channels/${encodeURIComponent(channel.id)}/encrypted`);
}}
>
<LockClosed className="size-3.5 shrink-0" />
<span>{$i18n.t('Encrypted Chat')}</span>
</button>
<hr class="border-gray-200/30 dark:border-gray-700/30" />
{/if}
<div class="px-2 pt-1 pb-0.5 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{$i18n.t('Notifications')}
</div>

View file

@ -674,7 +674,8 @@
}
await tick();
if (!isDestroyed && targetQueueKey === queueKey) chatInputElement?.focus();
if (!isDestroyed && targetQueueKey === queueKey)
chatInputElement?.focus({ preventScroll: true });
};
$: if (content) {
@ -945,7 +946,11 @@
<div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-sm rounded-2xl border border-gray-100/60 dark:border-gray-800/50 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'}
dir={$settings?.chatDirection === 'RTL'
? 'rtl'
: $settings?.chatDirection === 'LTR'
? 'ltr'
: 'auto'}
>
{#if replyToMessage !== null}
<div class="px-3 pt-2 text-left w-full flex flex-col z-10">
@ -1073,6 +1078,10 @@
}}
on:keydown={async (e) => {
e = e.detail.event;
// IME confirmation must finish before a shortcut can send and clear the editor.
if (e.isComposing || e.keyCode === 229 || inputEditor?.view.composing)
return;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const suggestionsContainerElement =

View file

@ -148,7 +148,7 @@
</div>
{/if}
{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
{#each messageList as message, messageIdx (id ? `${id}-${message.client_id ?? message.id}` : (message.client_id ?? message.id))}
<Message
on:reply={(event) =>
receiveAppChannelReply({ ...event.detail, parent_id: thread ? id : null })}

View file

@ -74,6 +74,11 @@
let editedContent = null;
let showDeleteConfirmDialog = false;
$: renderedMessageId = message ? (id ? `${id}-${message.id}` : message.id) : null;
$: contentId = message?.client_id
? id
? `${id}-${message.client_id}`
: message.client_id
: renderedMessageId;
$: replyToMessageId = message?.reply_to_message
? id
? `${id}-${message.reply_to_message.id}`
@ -392,7 +397,7 @@
class="text-xs text-gray-500 dark:text-gray-400 line-clamp-1 min-w-0 flex-1 [&_.markdown-prose]:text-xs!"
>
<Markdown
id={`${renderedMessageId}-reply-to`}
id={`${contentId}-reply-to`}
content={getAppMessageText(message?.reply_to_message)}
allowHtml={!message?.reply_to_message?.app_id}
allowEmbeds={false}
@ -511,6 +516,7 @@
</Tooltip>
</div>
{/if}
<slot name="status" />
</div>
{/if}
@ -599,28 +605,26 @@
? 'opacity-50'
: ''}"
>
{#if hasStructuredOutput}
<StructuredOutputRenderer
id={renderedMessageId}
output={messageOutput}
done={message?.meta?.done ?? false}
allowEmbeds={false}
/>
{:else if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
<span class="markdown-prose">
<Markdown
<slot name="content">
{#if hasStructuredOutput}
<StructuredOutputRenderer
id={renderedMessageId}
content={message.content}
paragraphTag={message.app_id ? 'p' : 'span'}
output={messageOutput}
done={message?.meta?.done ?? false}
allowEmbeds={false}
/>
</span>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
class="text-gray-500 text-[0.625rem] pl-1 self-center"
>({$i18n.t('edited')})</span
>{/if}
{/if}
{:else if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
<div class="markdown-prose">
<Markdown id={contentId} content={message.content} allowEmbeds={false} />
</div>
{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
class="text-gray-500 text-[0.625rem] pl-1 self-center"
>({$i18n.t('edited')})</span
>{/if}
{/if}
</slot>
</div>
{#if message.app_id && (message?.data?.app?.embeds?.length || message?.data?.app?.actions?.length)}

View file

@ -46,13 +46,16 @@
};
export let channel;
export let readOnly = false;
export let onPin = (messageId, pinned) => {};
export let onUpdate = () => {};
</script>
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} />
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} {onUpdate} />
{#if !readOnly}
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} />
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} {onUpdate} />
{/if}
<nav class="sticky top-0 z-30 w-full px-3 py-1 -mb-8 flex items-center drag-region flex-col">
<div
id="navbar-bg-gradient-to-b"
@ -60,12 +63,12 @@
></div>
<div class=" flex max-w-full w-full mx-auto pt-0.5 bg-transparent">
<div class="flex items-center w-full max-w-full">
<div class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full max-w-full">
{#if $mobile}
<div
class="{$showSidebar
? 'md:hidden'
: ''} mr-1.5 mt-0.5 self-center flex flex-none items-center text-gray-600 dark:text-gray-400"
: ''} col-start-1 row-start-1 mr-1.5 mt-0.5 self-center flex flex-none items-center text-gray-600 dark:text-gray-400"
>
<Tooltip
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
@ -87,7 +90,7 @@
{/if}
<div
class="min-w-0 flex-1 overflow-hidden max-w-full py-0.5 flex items-center
class="col-start-2 row-start-1 min-w-0 overflow-hidden max-w-full py-0.5 flex items-center
{$showSidebar ? 'ml-1' : ''}
"
>
@ -148,50 +151,58 @@
</div>
</div>
{/if}
<slot name="status" />
</div>
<div
class="self-center flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
class="col-start-3 row-start-1 self-center flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
>
{#if channel}
<Tooltip content={$i18n.t('Pinned Messages')}>
<button
class=" flex size-7 items-center justify-center cursor-pointer rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
aria-label={$i18n.t('Pinned Messages')}
type="button"
on:click={() => {
showChannelPinnedMessagesModal = true;
}}
>
<div class=" flex items-center gap-1 m-auto self-center shrink-0">
<Pin className=" size-4" strokeWidth="1.5" />
</div>
</button>
</Tooltip>
{#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}>
<slot name="actions">
{#if channel}
<Tooltip content={$i18n.t('Pinned Messages')}>
<button
class=" flex h-7 items-center cursor-pointer shrink-0 px-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
aria-label={$i18n.t('User Count')}
class=" flex size-7 items-center justify-center cursor-pointer rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
aria-label={$i18n.t('Pinned Messages')}
type="button"
on:click={() => {
showChannelInfoModal = true;
showChannelPinnedMessagesModal = true;
}}
>
<div class=" flex items-center gap-1 m-auto self-center shrink-0">
<UserAlt className=" size-4" strokeWidth="1.5" />
<div class="text-xs tabular-nums shrink-0">
{channel.user_count}
</div>
<Pin className=" size-4" strokeWidth="1.5" />
</div>
</button>
</Tooltip>
{#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}>
<button
class=" flex h-7 items-center cursor-pointer shrink-0 px-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
aria-label={$i18n.t('User Count')}
type="button"
on:click={() => {
showChannelInfoModal = true;
}}
>
<div class=" flex items-center gap-1 m-auto self-center shrink-0">
<UserAlt className=" size-4" strokeWidth="1.5" />
<div class="text-xs tabular-nums shrink-0">
{channel.user_count}
</div>
</div>
</button>
</Tooltip>
{/if}
<ChannelMenu {channel} />
{/if}
<ChannelMenu {channel} />
{/if}
</slot>
</div>
{#if $$slots.subtitle}
<div class="col-start-2 row-start-2 min-w-0 {$showSidebar ? 'ml-1' : ''}">
<slot name="subtitle" />
</div>
{/if}
</div>
</div>
</nav>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { socket, user } from '$lib/stores';
@ -21,6 +22,8 @@
export let onPin = () => {};
let messages = null;
let searchAnchor = '';
let loadVersion = 0;
let top = false;
let messagesContainerElement = null;
@ -32,7 +35,11 @@
let typingUsersTimeout = {};
$: if (threadId) {
initHandler();
initHandler(
$page.url.searchParams.get('thread') === threadId
? ($page.url.searchParams.get('message') ?? '')
: ''
);
}
const scrollToBottom = () => {
@ -41,22 +48,43 @@
}
};
const initHandler = async () => {
const initHandler = async (anchor = '') => {
const version = ++loadVersion;
searchAnchor = anchor;
messages = null;
top = false;
replyToMessage = null;
typingUsers = [];
typingUsersTimeout = {};
if (channel) {
messages = await getChannelThreadMessages(localStorage.token, channel.id, threadId);
const loaded = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
0,
50,
{ around: anchor }
).catch((error) => {
toast.error(String(error));
return [];
});
if (version !== loadVersion) return;
messages = loaded;
if (messages.length < 50) {
top = true;
}
await tick();
scrollToBottom();
if (anchor) {
const element = document.getElementById(`message-${threadId}-${anchor}`);
element?.scrollIntoView({ block: 'center' });
element?.animate([{ backgroundColor: '#a5a5c544' }, { backgroundColor: 'transparent' }], {
duration: 2200
});
} else scrollToBottom();
} else {
goto('/');
}
@ -217,6 +245,12 @@
bind:this={messagesContainerElement}
>
<div class="pt-3">
{#if searchAnchor}<div class="px-3 py-2 text-xs text-gray-500 flex justify-between">
<span>{$i18n.t('Showing search match')}</span><button
class="underline"
on:click={() => initHandler()}>{$i18n.t('Latest replies')}</button
>
</div>{/if}
{#if messages !== null}
<Messages
id={threadId}
@ -237,10 +271,17 @@
localStorage.token,
channel.id,
threadId,
messages.length
searchAnchor ? 0 : messages.length,
50,
searchAnchor ? { before: messages[messages.length - 1]?.id } : {}
);
messages = [...messages, ...newMessages];
messages = [
...messages,
...newMessages.filter(
(item) => !messages.some((existing) => existing.id === item.id)
)
];
if (newMessages.length < 50) {
top = true;

View file

@ -1,4 +1,5 @@
<script lang="ts">
import type { TerminalContext } from '$lib/types';
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
@ -94,6 +95,7 @@
updateChatById,
updateChatFolderIdById
} from '$lib/apis/chats';
import type { FilesystemFile } from '$lib/apis/terminal';
import { generateOpenAIChatCompletion, getErrorMessage } from '$lib/apis/openai';
import { processUrl, processWebSearch } from '$lib/apis/retrieval';
import {
@ -473,6 +475,21 @@
let prompt = '';
let chatFiles = [];
let files: any[] = [];
let terminalContext: TerminalContext | null = null;
const askFile = async (instruction: string, file: FilesystemFile) => {
const attachments = [structuredClone(file)];
const last = history.currentId ? history.messages[history.currentId] : null;
if (last?.role === 'assistant' && !last.done) {
chatRequestQueues.update((queues) => ({
...queues,
[$chatId]: [
...(queues[$chatId] ?? []),
{ id: uuidv4(), prompt: instruction, files: attachments }
]
}));
} else await submitPrompt(instruction, attachments);
};
let params = {};
let chatVariables = {};
let showChatVariablesModal = false;
@ -905,7 +922,7 @@
await tick();
loading = false;
noteChatDebug('embedded chat loading false');
window.setTimeout(() => scrollToBottom(), 0);
if (!$page.url.searchParams.has('message')) window.setTimeout(() => scrollToBottom(), 0);
await tick();
@ -938,6 +955,7 @@
};
const initEmbeddedDraft = async () => {
terminalContext = null;
clearTimeout(saveControlsTimer);
await saveControls();
@ -1062,15 +1080,18 @@
let lastTerminalSkillSelector: string | null = null;
$: if ($selectedTerminalId !== lastTerminalSkillSelector) {
terminalContext = null;
selectedSkillIds = selectedSkillIds.filter((id) => !id.startsWith('terminal:'));
terminalSkills.set([]);
lastTerminalSkillSelector = $selectedTerminalId;
}
let settingDefaults = false;
let settingDefaults: Promise<void> | null = null;
const setDefaults = async () => {
if (settingDefaults) return;
settingDefaults = true;
// Concurrent callers must wait, or the earlier run overwrites what they write afterwards
if (settingDefaults) return settingDefaults;
let resolveDefaults = () => {};
settingDefaults = new Promise<void>((resolve) => (resolveDefaults = resolve));
try {
if (!$tools) {
@ -1213,10 +1234,23 @@
}
}
} finally {
settingDefaults = false;
settingDefaults = null;
resolveDefaults();
}
};
let revealedSearchMessage = '';
$: if (!embedded && !loading && messagesRef && chat && chat.id === $page.params.id) {
const target = $page.url.searchParams.get('message');
const key = `${chat.id}:${target}`;
if (target && history.messages[target] && key !== revealedSearchMessage) {
revealedSearchMessage = key;
autoScroll = false;
history.currentId = getDeepestChildId(history, target);
tick().then(() => messagesRef?.scrollToMessage(target));
} else if (!target) revealedSearchMessage = '';
}
const showMessage = async (message, scroll = true, save = true) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
@ -1248,7 +1282,16 @@
});
};
const terminalEventHandler = (type: string, data: any) => {
const terminalEventHandler = (type: string, data: any, message: any) => {
const attachment = history.messages[message?.parentId]?.files?.find(
(file: any) => file.type === 'filesystem'
);
if (attachment) {
if (attachment.terminal_id && attachment.terminal_id !== $selectedTerminalId) return;
if (type === 'terminal:display_file' && terminalContext?.file?.path === data.path)
showFileNavDir.set(data.path);
return;
}
if (type === 'terminal:display_file') {
if (!data?.path) return;
if ($settings?.terminalFileDisplay === 'inline') return;
@ -1256,7 +1299,7 @@
} 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') {
} else if (type === 'terminal:run_command' && !terminalContext?.file) {
showFileNavDir.set('/');
}
};
@ -1299,6 +1342,8 @@
};
let joinedChatId = '';
let liveParticipants: { id: string; name: string }[] = [];
const clearPresence = () => (liveParticipants = []);
let chatRefresh: { id: string; promise: Promise<void> } | null = null;
let typingUsers: { id: string; name: string }[] = [];
let typingUsersTimeout: Record<string, ReturnType<typeof setTimeout>> = {};
@ -1337,7 +1382,17 @@
) => {
const currentId = history.currentId;
const next = incoming?.messages ?? {};
// An edited prompt adds a sibling; other viewers should keep their selected branch.
const hasNewBranch = Object.values(next).some(
(message: any) =>
message.role === 'user' &&
!history.messages[message.id] &&
Object.values(history.messages).some(
(existing: any) => existing.role === 'user' && existing.parentId === message.parentId
)
);
const follow =
!hasNewBranch &&
!generating &&
!(
taskIds?.length &&
@ -1384,6 +1439,7 @@
mergeChatMessages(fresh.chat.history, baseline);
} catch (error) {
if ($chatId === id && joinedChatId === id) {
clearPresence();
chat = null;
history = { messages: {}, currentId: null };
if (!embedded) await goto('/');
@ -1397,20 +1453,33 @@
};
const syncChatRoom = (socket: typeof $socket, id: string, temporary: boolean) => {
const next = socket && id && !temporary && !isTemporaryChatId(id) ? id : '';
const next = socket?.connected && id && !temporary && !isTemporaryChatId(id) ? id : '';
if (next === joinedChatId) return;
clearTyping();
if (joinedChatId)
socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
clearPresence();
if (joinedChatId && socket?.connected)
socket.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
joinedChatId = next;
if (next)
socket?.emit('events:chat', { chat_id: next, data: { type: 'join' } }, () => {
if ($chatId === next) void refreshChat();
const connectionId = socket?.id;
if (next && socket)
socket.emit('events:chat', { chat_id: next, data: { type: 'join' } }, (accepted: boolean) => {
if (!socket.connected || socket.id !== connectionId || $chatId !== next) return;
if (!accepted) clearPresence();
// A refused join can mean access was lost; the refresh then sends the reader home.
void refreshChat();
});
};
$: syncChatRoom($socket, $chatId, $temporaryChatEnabled);
const chatEventHandler = async (event, cb) => {
if (event?.data?.type === 'chat:presence') {
if (!$socket?.connected || event.chat_id !== $chatId || event.chat_id !== joinedChatId)
return;
liveParticipants = (event.data.data?.users ?? []).filter(
(person: { id: string; name: string }) => person.id !== $user?.id
);
return;
}
console.log(event);
if (event?.data?.type === 'voice:play_animation') {
cb?.(
@ -1716,7 +1785,7 @@
eventConfirmationInputType = data?.input?.type ?? data?.type ?? '';
eventConfirmationInputOptions = data?.input?.options ?? data?.options ?? [];
} else if (type.startsWith('terminal:')) {
terminalEventHandler(type, data);
terminalEventHandler(type, data, message);
} else {
console.log('Unknown message type', data);
}
@ -1846,6 +1915,7 @@
$socket?.on('events', chatEventHandler);
$socket?.on('connect', handleSocketConnect);
$socket?.on('disconnect', clearTyping);
$socket?.on('disconnect', clearPresence);
$socket?.on('disconnect', cancelBrowserInteractions);
$audioQueue?.destroy();
@ -1943,8 +2013,9 @@
// to the admin panel), otherwise the previously-viewed chat stays selected
// in the sidebar and deleting/archiving it wrongly navigates away.
clearTyping();
if (joinedChatId)
$socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
clearPresence();
if (joinedChatId && $socket?.connected)
$socket.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
joinedChatId = '';
chatId.set('');
chatTitle.set('');
@ -1953,6 +2024,7 @@
$socket?.off('events', chatEventHandler);
$socket?.off('connect', handleSocketConnect);
$socket?.off('disconnect', clearTyping);
$socket?.off('disconnect', clearPresence);
$socket?.off('disconnect', cancelBrowserInteractions);
dismissContextCompactionToast();
audioQueueInstance?.destroy();
@ -2544,6 +2616,7 @@
};
const initNewChat = async () => {
if ($chatId) terminalContext = null;
cancelBrowserInteractions();
bridge?.end();
console.log('initNewChat');
@ -2679,7 +2752,10 @@
// resetInput() must stay last: the selected model's defaults override the draft's selection.
await restoreChatInput(sessionStorage.getItem('chat-input'));
const previousChatId = $chatId;
await resetInput();
// A chat opened in the meantime keeps its id
if ($chatId !== previousChatId) return;
await chatId.set('');
await chatTitle.set('');
@ -2787,6 +2863,7 @@
};
const loadChat = async () => {
if (chatIdProp && chatIdProp !== $chatId) terminalContext = null;
noteChatDebug('loadChat start');
// chatIdProp is empty for chats started from the home page (URL set via replaceState)
chatId.set(chatIdProp || $chatId);
@ -3019,10 +3096,10 @@
let processingQueueChats = new Set<string>();
const processNextInQueue = async (targetChatId: string) => {
if (processingQueueChats.has(targetChatId)) return;
if (processingQueueChats.has(targetChatId) || targetChatId !== $chatId) return;
const queue = $chatRequestQueues[targetChatId];
if (!queue || queue.length === 0) return;
if (!queue || queue.length === 0 || queue[0].error) return;
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
if (
(lastMessage && lastMessage.role === 'assistant' && !lastMessage.done) ||
@ -3034,7 +3111,11 @@
}
processingQueueChats.add(targetChatId);
const queuedMessages = [...queue];
const boundary = queue.findIndex((entry) =>
entry.files.some((file: any) => file.type === 'filesystem')
);
const queuedMessages =
boundary === 0 ? queue.slice(0, 1) : boundary < 0 ? [...queue] : queue.slice(0, boundary);
const queuedMessageIds = new Set(queuedMessages.map((m) => m.id));
try {
const combinedPrompt = queuedMessages.map((m) => m.prompt).join('\n\n');
@ -3050,7 +3131,10 @@
console.error(error);
chatRequestQueues.update((q) => ({
...q,
[targetChatId]: [...queuedMessages, ...(q[targetChatId] ?? [])]
[targetChatId]: [
...queuedMessages.map((entry) => ({ ...entry, error: String(error) })),
...(q[targetChatId] ?? [])
]
}));
} finally {
processingQueueChats.delete(targetChatId);
@ -3076,6 +3160,12 @@
await stopResponse(false);
await tick();
await submitPrompt(item.prompt, item.files);
} catch (error) {
toast.error(String(error));
chatRequestQueues.update((q) => ({
...q,
[targetChatId]: [{ ...item, error: String(error) }, ...(q[targetChatId] ?? [])]
}));
} finally {
processingQueueChats.delete(targetChatId);
// Completion can arrive before submitPrompt returns, while the queue is locked.
@ -3526,7 +3616,9 @@
saveSessionSelectedModels();
return await sendMessage(history, userMessageId, { modelId: bridgeRequest?.modelId ?? null });
return await sendMessage(history, userMessageId, {
modelId: bridgeRequest?.modelId ?? null
});
};
const handleManualCompact = async () => {
@ -3810,7 +3902,11 @@
files = [];
messageInput?.setText('');
return (await submitPrompt(userPrompt, _files, bridgeRequest)) ?? { status: 'rejected' };
return (
(await submitPrompt(userPrompt, _files, bridgeRequest)) ?? {
status: 'rejected'
}
);
};
const sendMessage = async (
@ -3828,6 +3924,21 @@
regenerationPrompt?: string | null;
} = {}
) => {
const parent = _history.messages[parentId];
if (parent?.files?.some((file: any) => file.type === 'filesystem')) {
const attached = parent.files.filter((file: any) => file.type === 'filesystem');
const terminal = attached.find((file: any) => file.terminal_id)?.terminal_id;
if (
attached.some(
(file: any) =>
(file.terminal_id && file.terminal_id !== terminal) ||
(file.session_id && file.session_id !== $chatId)
)
)
throw new Error('Attached files must belong to this chat and the same terminal.');
if (terminal && !isTerminalAvailable(terminal))
throw new Error('The attached file’s terminal is unavailable.');
}
if (autoScroll) {
scrollToBottom();
}
@ -4046,6 +4157,10 @@
) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
const filesystemAttachment = userMessage?.files?.find(
(file: any) => file.type === 'filesystem'
);
const requestTerminalId = filesystemAttachment?.terminal_id ?? $selectedTerminalId;
const chatMessageFiles = _messages
.filter((message) => message.files)
@ -4173,8 +4288,8 @@
}
}
// Menu-selected skills are sent as IDs; inline <$skillId|label> mentions stay
// in the message so the backend can inject their full content.
// Menu-selected skills and inline <$skillId|label> mentions both load instructions.
// Keep mentions in the message so the backend can resolve them.
const skillIds = [...selectedSkillIds];
// Only send terminal_id if the model has terminal capability enabled
@ -4208,7 +4323,7 @@
: $config?.features?.enable_tools
),
skill_ids: skillIds.length > 0 ? skillIds : undefined,
terminal_id: terminalEnabled && $selectedTerminalId ? $selectedTerminalId : undefined,
terminal_id: terminalEnabled && requestTerminalId ? requestTerminalId : undefined,
tool_servers: $config?.features?.enable_tool_servers
? [
...($toolServers ?? []).filter(
@ -4217,7 +4332,10 @@
// Direct terminal servers — always included when enabled (not routed through selectedToolIds)
...(terminalEnabled
? ($terminalServers ?? [])
.filter((server) => !server.id)
.filter(
(server) =>
!server.id && (!filesystemAttachment || server.url === requestTerminalId)
)
.map((server) => ({ ...server, is_terminal: true }))
: [])
]
@ -4286,6 +4404,8 @@
return null;
});
if (filesystemAttachment && (!res || res.error))
throw new Error('File request failed. Retry from the message or queue.');
if (res) {
if (res.error) {
await handleOpenAIError(res.error, responseMessage);
@ -4304,9 +4424,25 @@
const { [_chatId]: pendingQueue, ...rest } = q;
return {
...rest,
[res.chat_id]: [...pendingQueue, ...(rest[res.chat_id] ?? [])]
[res.chat_id]: [
...pendingQueue.map((entry) => ({
...entry,
files: entry.files.map((file) =>
file.type === 'filesystem' && file.session_id == null
? { ...file, session_id: res.chat_id }
: file
)
})),
...(rest[res.chat_id] ?? [])
]
};
});
for (const file of userMessage?.files ?? [])
if (file.type === 'filesystem' && file.session_id == null)
file.session_id = res.chat_id;
for (const file of history.messages[userMessage.id]?.files ?? [])
if (file.type === 'filesystem' && file.session_id == null)
file.session_id = res.chat_id;
await chatId.set(res.chat_id);
if (!$temporaryChatEnabled && !embedded) {
// Carry the picks made on the new-chat page over to this chat
@ -4469,12 +4605,12 @@
}
};
const continueResponse = async () => {
const continueResponse = async (message) => {
console.log('continueResponse');
const _chatId = JSON.parse(JSON.stringify($chatId));
if (history.currentId && history.messages[history.currentId].done == true) {
const responseMessage = history.messages[history.currentId];
if (history.messages[message.id].done == true) {
const responseMessage = history.messages[message.id];
responseMessage.done = false;
await tick();
@ -4984,6 +5120,7 @@
</div>
{:else}
<Navbar
{liveParticipants}
bind:this={navbarElement}
readOnly={isShared}
chat={{
@ -5053,6 +5190,7 @@
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
if (!messagesContainerElement) return;
autoScroll =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 5;
@ -5114,6 +5252,7 @@
class=" pb-2 {dragged ? 'z-0' : 'z-10'}"
>
<MessageInput
{terminalContext}
{typingUsers}
on:typing={(event) => emitTyping(event.detail)}
callActive={!!(bridge?.connected || bridge?.connecting)}
@ -5210,6 +5349,7 @@
{/if}
<div id={embedded ? messageInputDropzoneId : undefined} class="pb-2 z-10">
<MessageInput
{terminalContext}
{typingUsers}
on:typing={(event) => emitTyping(event.detail)}
callActive={!!(bridge?.connected || bridge?.connecting)}
@ -5275,6 +5415,7 @@
{:else}
<div class="flex items-center h-full">
<Placeholder
{terminalContext}
callActive={!!(bridge?.connected || bridge?.connecting)}
callEnabled={!isShared}
bind:selectedModelIdx
@ -5331,6 +5472,7 @@
{#if !embedded}
<ChatControls
bind:terminalContext
bind:history
bind:chatFiles
bind:params
@ -5346,6 +5488,7 @@
return a;
}, [])}
submitPrompt={submitHandler}
onAskFile={askFile}
{stopResponse}
{bridge}
bind:callMode

View file

@ -3,6 +3,7 @@
</script>
<script lang="ts">
import type { TerminalContext } from '$lib/types';
import { onMount, tick, getContext } from 'svelte';
import {
config,
@ -34,6 +35,7 @@
export let models = [];
export let chatId = null;
export let terminalContext: TerminalContext | null = null;
export let chatUser = null;
export let chatFiles = [];
@ -43,6 +45,9 @@
export let callMode = 'current';
export let eventTarget: EventTarget;
export let submitPrompt: Function;
export let onAskFile:
| ((instruction: string, file: import('$lib/apis/terminal').FilesystemFile) => Promise<void>)
| null = null;
export let stopResponse: Function;
export let showMessage: Function;
export let files;
@ -231,7 +236,7 @@
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#if showControlsTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'controls'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
@ -242,7 +247,7 @@
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'files'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
@ -253,7 +258,7 @@
{/if}
{#if showOverviewTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'overview'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
@ -298,7 +303,7 @@
}}
/>
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
<FileNav {chatId} />
<FileNav {onAskFile} {chatId} bind:terminalContext />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav />
{:else}
@ -356,7 +361,7 @@
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#if showControlsTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'controls'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
@ -367,7 +372,7 @@
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'files'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
@ -378,7 +383,7 @@
{/if}
{#if showOverviewTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'overview'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
@ -428,7 +433,7 @@
}}
/>
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
<FileNav overlay={dragged} {chatId} />
<FileNav {onAskFile} overlay={dragged} {chatId} bind:terminalContext />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}

View file

@ -12,7 +12,13 @@
</script>
<script lang="ts">
import type { TerminalContext } from '$lib/types';
import { filesystemFile, type FilesystemFile } from '$lib/apis/terminal';
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
import { toast } from 'svelte-sonner';
import Plus from '$lib/components/icons/Plus.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import { getContext, onMount, onDestroy, tick } from 'svelte';
import type { Readable } from 'svelte/store';
import type { i18n as I18n } from 'i18next';
@ -55,6 +61,8 @@
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FilePreview from './FileNav/FilePreview.svelte';
import CommentFilePreview from './FileNav/CommentFilePreview.svelte';
import { supportsComments } from '$lib/utils/comments/types';
import FileComparisonModal from './FileNav/FileComparisonModal.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte';
import Icon from './FileNav/Icon.svelte';
@ -68,6 +76,189 @@
export let overlay = false;
export let chatId: string | null = null;
export let terminalContext: TerminalContext | null = null;
export let onAskFile: ((instruction: string, file: FilesystemFile) => Promise<void>) | null =
null;
let viewerRoot: HTMLDivElement;
let fileAsk: {
text: string;
location?: string;
anchor: Range | HTMLElement;
} | null = null;
// Keep typing from invalidating the keyed selection and remounting the input.
let fileAskInput = '';
let asking = false;
const positionFileAsk = (node: HTMLFormElement, anchor: Range | HTMLElement) => {
document.body.appendChild(node);
const reference = {
getBoundingClientRect: () => anchor.getBoundingClientRect(),
contextElement: viewerRoot
};
const cleanup = autoUpdate(reference, node, () => {
computePosition(reference, node, {
strategy: 'fixed',
placement: anchor instanceof Range ? 'bottom-start' : 'bottom-end',
middleware: [offset(6), flip(), shift({ padding: 8 })]
}).then(({ x, y }) => {
node.style.left = `${x}px`;
node.style.top = `${y}px`;
});
});
return {
destroy() {
cleanup();
node.remove();
}
};
};
const submitFileAsk = async () => {
const request = fileAsk;
const instruction = fileAskInput.trim();
if (!request || !instruction || asking || !onAskFile) return;
asking = true;
try {
const file = await prepareFile();
if (fileAsk !== request) return;
const quote = [request.location, request.text]
.filter(Boolean)
.join('\n')
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
await onAskFile(
request.text || request.location ? `${quote}\n\n${instruction}` : instruction,
file
);
if (fileAsk === request) fileAsk = null;
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
asking = false;
}
};
let openedFile: FilesystemFile | null = null;
let pendingFile: FilesystemFile | null = null;
let viewerLabel = '';
let loadGeneration = 0;
let dirty = false;
let diskChanged = false;
let saveConnection: { url: string; key: string; chatId: string | null; path: string } | null =
null;
let dirtyChoice: {
sending: boolean;
resolve: (choice: 'save' | 'discard' | 'cancel') => void;
} | null = null;
const protectBuffer = async (sending = false) => {
if (!dirty && !saving) return true;
if (saving || dirtyChoice) return false;
const choice = await new Promise<'save' | 'discard' | 'cancel'>((resolve) => {
dirtyChoice = { sending, resolve };
});
dirtyChoice = null;
if (choice === 'cancel') return false;
if (choice === 'save') {
try {
await filePreviewRef.save();
} catch (error) {
toast.error(String(error));
return false;
}
if (dirty) return false;
}
if (choice === 'discard') {
filePreviewRef?.discard();
dirty = false;
}
return true;
};
const publishFile = () => {
if (!openedFile) return;
terminalContext = {
terminalId: openedFile.terminal_id!,
currentDirectory: currentPath,
file: openedFile
};
};
const captureSelection = () => {
const selection = window.getSelection();
if (!onAskFile || !openedFile || !selection?.rangeCount || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
if (!viewerRoot?.contains(range.startContainer) || !viewerRoot.contains(range.endContainer))
return;
if (
range.startContainer.parentElement?.closest(
'button, select, .excel-row-num, .excel-col-hdr, .csv-row-num'
)
)
return;
const source = filePreviewRef?.getSelection();
const text = source?.text ?? selection.toString();
if (!text.trim()) return;
if (new TextEncoder().encode(text).length > 16384) {
toast.error('Select less text (maximum 16 KiB).');
return;
}
const page =
range.startContainer.parentElement?.closest<HTMLElement>('[data-page-number]')?.dataset
.pageNumber;
const location = source?.location ?? (page ? `Page ${page}` : viewerLabel);
fileAskInput = '';
fileAsk = {
text,
location,
anchor: range.cloneRange()
};
};
const prepareFile = async (): Promise<FilesystemFile> => {
const target = openedFile;
if (!target) throw new Error('Wait for the file to load.');
if (!(await protectBuffer(true))) throw new Error('File request cancelled.');
if (
!openedFile ||
openedFile.path !== target.path ||
openedFile.terminal_id !== $selectedTerminalId ||
openedFile.session_id !== chatId
)
throw new Error('The file or terminal changed. Open it again before sending.');
if (diskChanged)
throw new Error('The file changed on disk. Reload and select the content again.');
return structuredClone(openedFile);
};
$: if (previewPort !== null && openedFile) {
openedFile = null;
if (terminalContext) terminalContext = { ...terminalContext, file: null };
}
$: {
(selectedFile, $selectedTerminalId, chatId);
fileAsk = null;
}
$: viewerLabel = selectedExcelSheet
? `Sheet ${selectedExcelSheet}`
: /\.(pdf|docx|pptx)$/i.test(selectedFile ?? '')
? `Page ${currentSlide + 1}`
: '';
// Preserve translations for the former save notifications during extraction.
// $i18n.t('File saved'); $i18n.t('Failed to save file');
const saveFile = async (content: string) => {
const target = saveConnection;
if (!target || !selectedFileWritable) throw new Error('File is not writable.');
const name = target.path.split('/').pop()!;
const dir = target.path.slice(0, target.path.lastIndexOf('/') + 1) || '/';
const result = await uploadToTerminal(
target.url,
target.key,
dir,
new File([content], name, { type: 'text/plain' }),
target.chatId ?? undefined
);
if (!result) throw new Error('Failed to save file. Your changes are still in the editor.');
if (saveConnection === target) {
fileContent = content;
diskChanged = false;
}
await tick();
};
// ── Terminal panel state ────────────────────────────────────────────
let terminalExpanded = false;
@ -335,6 +526,7 @@
let fileDocxData: ArrayBuffer | null = null;
let fileLoading = false;
let filePreviewRef: FilePreview;
let commentGeneration = 0;
let fileSearchTarget: FileSearchTarget | null = null;
let documentTargetPage: number | null = null;
@ -354,7 +546,7 @@
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 OFFICE_EXTS = new Set(['docx', 'xlsx', 'xls', 'pptx']);
const getFileExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
$: isMarkdown = MD_EXTS.has(getFileExt(selectedFile));
@ -452,10 +644,17 @@
if (mounted && terminal) {
if (chatChanged && chatId && !oldChatId) {
if (openedFile) {
openedFile = { ...openedFile, session_id: chatId };
publishFile();
}
// Chat just got created (null → real ID): persist the current
// browsed path as the new session's cwd — don't re-fetch.
setCwd(terminal.url, terminal.key, savedPath, chatId);
} else if (terminalChanged || chatChanged) {
++loadGeneration;
openedFile = null;
if (terminalContext) terminalContext = { ...terminalContext, file: null };
// Terminal switched, new chat started, or switched between
// existing chats — re-fetch the session cwd.
loading = true;
@ -729,6 +928,7 @@
// ── File preview management ──────────────────────────────────────────
const clearFilePreview = () => {
commentGeneration++;
fileSearchTarget = null;
documentTargetPage = null;
fileContent = null;
@ -760,6 +960,10 @@
path: string,
options: { preserveTree?: boolean; restoreTree?: boolean } = {}
) => {
if (!(await protectBuffer())) return;
const generation = ++loadGeneration;
const selector = $selectedTerminalId,
session = chatId;
const terminal = selectedTerminal;
if (!terminal) return;
const directory = clampToFileRoot(path);
@ -769,6 +973,9 @@
resetTreeState();
}
openedFile = null;
saveConnection = null;
dirty = false;
loading = true;
error = null;
selectedFile = null;
@ -777,10 +984,13 @@
clearFilePreview();
clearSelection();
currentPath = directory;
terminalContext = { terminalId: $selectedTerminalId, currentDirectory: directory };
savedPath = directory;
pushNavHistory(directory);
const result = await listFiles(terminal.url, terminal.key, directory, chatId ?? undefined);
if (generation !== loadGeneration || selector !== $selectedTerminalId || session !== chatId)
return;
loading = false;
// Set working directory on the terminal server (fire-and-forget)
@ -850,7 +1060,10 @@
}
};
const openEntry = async (entry: FileEntry, options: { page?: unknown } = {}) => {
const openEntry = async (
entry: FileEntry,
options: { page?: unknown; refresh?: boolean } = {}
) => {
const fullPath =
'fullPath' in entry ? (entry as BrowserRow).fullPath : entryPath(currentPath, entry);
const parentPath = 'parentPath' in entry ? (entry as BrowserRow).parentPath : currentPath;
@ -859,6 +1072,11 @@
return;
}
if (options.refresh && (dirty || saving)) {
diskChanged = true;
return;
}
if (!(await protectBuffer())) return;
const filePath = fullPath;
if (parentPath !== currentPath) {
await loadDir(parentPath);
@ -869,11 +1087,55 @@
const terminal = selectedTerminal;
if (!terminal) return;
const generation = ++loadGeneration;
const selector = $selectedTerminalId!,
session = chatId;
const previousView =
openedFile?.path === filePath
? { showRaw, currentSlide, selectedExcelSheet, scroll: viewerRoot?.scrollTop ?? 0 }
: null;
const current = () =>
generation === loadGeneration && selector === $selectedTerminalId && session === chatId;
openedFile = null;
if (terminalContext) terminalContext = { ...terminalContext, file: null };
dirty = false;
diskChanged = false;
saveConnection = { ...terminal, path: filePath, chatId: session };
pendingFile = filesystemFile(
{ path: filePath, name: entry.name, size: entry.size, modified: entry.modified },
selector,
session
);
selectedFile = filePath;
fileLoading = true;
clearFilePreview();
documentTargetPage = normalizeDocumentTargetPage(options.page);
documentTargetPage = normalizeDocumentTargetPage(
options.page ?? (previousView ? previousView.currentSlide + 1 : null)
);
if (supportsComments(filePath)) {
if (previousView) {
currentSlide = previousView.currentSlide;
selectedExcelSheet = previousView.selectedExcelSheet;
}
fileLoading = false;
return;
}
const next = {
fileImageUrl: null,
fileVideoUrl: null,
fileAudioUrl: null,
filePdfData: null,
fileSqliteData: null,
fileDocxData: null,
fileOfficeHtml: null,
fileOfficeSlides: null,
excelWorkbook: null,
excelSheetNames: [],
selectedExcelSheet: '',
currentSlide: 0,
fileContent: null
} as any;
if (isImage(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -881,7 +1143,7 @@
filePath,
chatId ?? undefined
);
if (result) fileImageUrl = URL.createObjectURL(result.blob);
if (result) next.fileImageUrl = URL.createObjectURL(result.blob);
} else if (isVideo(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -889,7 +1151,7 @@
filePath,
chatId ?? undefined
);
if (result) fileVideoUrl = URL.createObjectURL(result.blob);
if (result) next.fileVideoUrl = URL.createObjectURL(result.blob);
} else if (isAudio(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -897,7 +1159,7 @@
filePath,
chatId ?? undefined
);
if (result) fileAudioUrl = URL.createObjectURL(result.blob);
if (result) next.fileAudioUrl = URL.createObjectURL(result.blob);
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -905,7 +1167,7 @@
filePath,
chatId ?? undefined
);
if (result) filePdfData = await result.blob.arrayBuffer();
if (result) next.filePdfData = await result.blob.arrayBuffer();
} else if (isSqlite(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -913,7 +1175,7 @@
filePath,
chatId ?? undefined
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
if (result) next.fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const ext = getFileExt(filePath);
try {
@ -925,7 +1187,7 @@
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
next.filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
@ -935,7 +1197,7 @@
);
if (!result) throw new Error('Preview failed');
const arrayBuffer = await result.blob.arrayBuffer();
fileDocxData = arrayBuffer;
next.fileDocxData = arrayBuffer;
}
} else if (ext === 'xlsx') {
const result = await downloadFileBlob(
@ -948,14 +1210,14 @@
const arrayBuffer = await result.blob.arrayBuffer();
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];
next.excelWorkbook = wb;
next.excelSheetNames = wb.SheetNames;
if (next.excelSheetNames.length > 0) {
next.selectedExcelSheet = next.excelSheetNames[0];
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(wb.Sheets[selectedExcelSheet]);
const result = await excelToTable(wb.Sheets[next.selectedExcelSheet]);
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(result.html);
next.fileOfficeHtml = DOMPurify.sanitize(result.html);
}
}
} else if (ext === 'pptx') {
@ -966,7 +1228,7 @@
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
next.filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
@ -978,20 +1240,62 @@
const arrayBuffer = await result.blob.arrayBuffer();
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const fallback = await pptxToImages(arrayBuffer);
fileOfficeSlides = fallback.images;
currentSlide = 0;
next.fileOfficeSlides = fallback.images;
next.currentSlide = 0;
}
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = $i18n.t('Error previewing file: {{error}}', {
next.fileContent = $i18n.t('Error previewing file: {{error}}', {
error: e instanceof Error ? e.message : $i18n.t('Unknown error')
});
}
} else {
fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
next.fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
}
if (!current()) {
[next.fileImageUrl, next.fileVideoUrl, next.fileAudioUrl]
.filter(Boolean)
.forEach((url) => URL.revokeObjectURL(url));
return;
}
fileImageUrl = next.fileImageUrl;
fileVideoUrl = next.fileVideoUrl;
fileAudioUrl = next.fileAudioUrl;
filePdfData = next.filePdfData;
fileSqliteData = next.fileSqliteData;
fileDocxData = next.fileDocxData;
fileOfficeHtml = next.fileOfficeHtml;
fileOfficeSlides = next.fileOfficeSlides;
excelWorkbook = next.excelWorkbook;
excelSheetNames = next.excelSheetNames;
selectedExcelSheet = next.selectedExcelSheet;
currentSlide = next.currentSlide;
fileContent = next.fileContent;
fileLoading = false;
if (
next.fileContent !== null ||
[
next.fileImageUrl,
next.fileVideoUrl,
next.fileAudioUrl,
next.filePdfData,
next.fileSqliteData,
next.fileDocxData,
next.fileOfficeHtml,
next.fileOfficeSlides
].some(Boolean)
) {
openedFile = pendingFile;
publishFile();
}
if (previousView) {
showRaw = previousView.showRaw;
currentSlide = previousView.currentSlide;
await tick();
if (viewerRoot) viewerRoot.scrollTop = previousView.scroll;
}
};
const openFileMatch = async (match: TerminalFileMatch) => {
@ -1504,7 +1808,12 @@
const fileName = filePath.substring(lastSlash + 1);
// Always reload directory to ensure entries are fresh
if (!(await protectBuffer())) return;
const selector = $selectedTerminalId,
session = chatId;
await loadDir(dir);
if (selector !== $selectedTerminalId || session !== chatId || currentPath !== dir || dirty)
return;
await tick();
const entry = entries.find((e) => e.name === fileName);
@ -1530,9 +1839,9 @@
if (comparison) return;
if (selectedFile) {
if (selectedFile === filePath || currentPath.startsWith(dir)) {
if (selectedFile === filePath) {
const fileName = selectedFile.split('/').pop() ?? '';
await openEntry({ name: fileName, type: 'file', size: 0 });
await openEntry({ name: fileName, type: 'file', size: 0 }, { refresh: true });
}
} else {
if (currentPath.startsWith(dir) || dir.startsWith(currentPath)) {
@ -1550,12 +1859,11 @@
terminalEnabled = config?.features?.terminal !== false;
const serverCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
const useServerPath = !!chatId || savedPath === '/';
const serverPath = applyCwd(serverCwd);
if (useServerPath) {
// Fetch session-specific cwd from the server (or global default for new chats)
savedPath = serverPath;
}
savedPath =
terminalContext && terminalContext.terminalId === $selectedTerminalId
? terminalContext.currentDirectory
: serverPath;
savedPath = clampToFileRoot(savedPath);
loadDir(savedPath, { restoreTree: true });
})();
@ -1600,6 +1908,8 @@
});
onDestroy(() => {
++loadGeneration;
dirtyChoice?.resolve('cancel');
clearMatchRequest();
if (fileImageUrl) URL.revokeObjectURL(fileImageUrl);
if (fileVideoUrl) URL.revokeObjectURL(fileVideoUrl);
@ -1621,7 +1931,19 @@
}}
/>
<svelte:window on:keydown={handleKeydown} on:click={handleWindowClick} />
<svelte:window
on:keydown={(event) => {
if (event.key === 'Escape' && fileAsk) {
fileAsk = null;
event.preventDefault();
} else handleKeydown(event);
}}
on:click={handleWindowClick}
on:pointerdown={(event) => {
if (fileAsk && !dirtyChoice && !(event.target as Element)?.closest('[data-file-ask]'))
fileAsk = null;
}}
/>
{#if comparison}
{#key comparison}
@ -1721,7 +2043,8 @@
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={() => {
on:click={async () => {
if (!(await protectBuffer())) return;
if (editing) filePreviewRef?.cancelEdit();
showRaw = !showRaw;
}}
@ -1740,7 +2063,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1757,7 +2081,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1774,7 +2099,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1798,7 +2124,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveEdit()}
on:click={() =>
filePreviewRef?.saveEdit().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1912,8 +2239,26 @@
{/if}
{/if}
{#if diskChanged}<div
role="status"
class="flex flex-wrap items-center gap-2 px-2 py-1.5 text-xs text-gray-500 dark:text-gray-400"
>
File updated on disk. Your local changes are preserved. <button
class="underline"
on:click={() => openEntry({ name: selectedFile!.split('/').pop()!, type: 'file' })}
>Reload</button
>
<button
class="underline"
on:click={() => filePreviewRef.save().catch((e) => toast.error(String(e)))}
>Save local changes</button
>
</div>{/if}
<!-- Content -->
<div
bind:this={viewerRoot}
on:mouseup={captureSelection}
on:keyup={captureSelection}
class="flex-1 overflow-y-auto min-h-0 min-w-0"
on:click={(e) => {
closeDirectoryMenu();
@ -1936,58 +2281,64 @@
}}
/>
{:else if selectedFile !== null}
<FilePreview
bind:this={filePreviewRef}
bind:editing
bind:showRaw
bind:saving
bind:currentSlide
{selectedFile}
readOnly={!selectedFileWritable}
{fileLoading}
{fileImageUrl}
{fileVideoUrl}
{fileAudioUrl}
{filePdfData}
{fileSqliteData}
{fileDocxData}
{fileContent}
{fileOfficeHtml}
{fileOfficeSlides}
targetPage={documentTargetPage}
{excelSheetNames}
{selectedExcelSheet}
searchTarget={fileSearchTarget}
onSheetChange={async (sheet) => {
if (!excelWorkbook) return;
selectedExcelSheet = sheet;
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(excelWorkbook.Sheets[sheet]);
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(result.html);
}}
baseUrl={previewBaseUrl}
apiKey={selectedTerminal?.key ?? ''}
overlay={overlay || isDraggingHandle}
onSave={async (content) => {
const terminal = selectedTerminal;
if (!terminal || !selectedFile || !selectedFileWritable) return;
const fileName = selectedFile.split('/').pop() ?? 'file';
const dir = selectedFile.substring(0, selectedFile.lastIndexOf('/') + 1) || '/';
const file = new File([content], fileName, { type: 'text/plain' });
const result = await uploadToTerminal(
terminal.url,
terminal.key,
dir,
file,
chatId ?? undefined
);
toast[result ? 'success' : 'error'](
result ? $i18n.t('File saved') : $i18n.t('Failed to save file')
);
if (result) fileContent = content;
}}
/>
{#if supportsComments(selectedFile) && selectedTerminal}
{#key `${selectedTerminal.url}|${selectedTerminal.key}|${chatId}|${selectedFile}|${commentGeneration}`}
<CommentFilePreview
path={selectedFile}
baseUrl={selectedTerminal.url}
apiKey={selectedTerminal.key}
{chatId}
targetPage={documentTargetPage}
initialSheet={selectedExcelSheet}
onReady={() => {
openedFile = pendingFile;
publishFile();
}}
bind:currentSlide
bind:sheet={selectedExcelSheet}
/>
{/key}
{:else}
<FilePreview
bind:this={filePreviewRef}
bind:editing
bind:showRaw
bind:saving
bind:dirty
{chatId}
resourceVersion={commentGeneration}
onLocalLink={(path) => showFileNavPath.set({ path })}
bind:currentSlide
{selectedFile}
readOnly={!selectedFileWritable}
{fileLoading}
{fileImageUrl}
{fileVideoUrl}
{fileAudioUrl}
{filePdfData}
{fileSqliteData}
{fileDocxData}
{fileContent}
{fileOfficeHtml}
{fileOfficeSlides}
targetPage={documentTargetPage}
{excelSheetNames}
{selectedExcelSheet}
searchTarget={fileSearchTarget}
onSheetChange={async (sheet) => {
if (!excelWorkbook) return;
selectedExcelSheet = sheet;
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(excelWorkbook.Sheets[sheet]);
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(result.html);
}}
baseUrl={previewBaseUrl}
apiKey={selectedTerminal?.key ?? ''}
overlay={overlay || isDraggingHandle}
onSave={saveFile}
/>
{/if}
{:else}
{#if isSearching}
{#if matchLoading}
@ -2126,13 +2477,14 @@
{#if !isSearching && !loading && !error && !uploading && !($selectedTerminalId && $terminalServers === null)}
{#if creatingFolder}
<div class="flex h-7 items-center gap-2 px-2">
<div class="flex h-7 items-center gap-1.5 pl-[26px] pr-2">
<FileTypeIcon name={newFolderName} type="directory" />
<input
bind:this={newFolderInput}
bind:value={newFolderName}
class="flex-1 text-xs bg-transparent border border-gray-100 dark:border-white/[0.06] rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500"
class="min-w-0 flex-1 bg-transparent py-0.5 text-xs outline-hidden"
placeholder={$i18n.t('Folder name')}
aria-label={$i18n.t('Folder name')}
on:keydown={(e) => {
if (e.key === 'Enter') submitNewFolder();
if (e.key === 'Escape') {
@ -2145,13 +2497,14 @@
</div>
{/if}
{#if creatingFile}
<div class="flex h-7 items-center gap-2 px-2">
<div class="flex h-7 items-center gap-1.5 pl-[26px] pr-2">
<FileTypeIcon name={newFileName} type="file" />
<input
bind:this={newFileInput}
bind:value={newFileName}
class="flex-1 text-xs bg-transparent border border-gray-100 dark:border-white/[0.06] rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500"
class="min-w-0 flex-1 bg-transparent py-0.5 text-xs outline-hidden"
placeholder={$i18n.t('File name')}
aria-label={$i18n.t('File name')}
on:keydown={(e) => {
if (e.key === 'Enter') submitNewFile();
if (e.key === 'Escape') {
@ -2289,26 +2642,26 @@
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
disabled={!currentWritable}
on:click={() => {
closeDirectoryMenu();
startNewFile();
}}
>
<Icon name="empty-page" size={12} strokeWidth={1.4} />
<Plus className="size-3.5" />
<span>{$i18n.t('New File')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
disabled={!currentWritable}
on:click={() => {
closeDirectoryMenu();
startNewFolder();
}}
>
<Icon name="folder" size={12} strokeWidth={1.4} />
<Folder className="size-3.5" />
<span>{$i18n.t('New Folder')}</span>
</button>
<button
@ -2362,3 +2715,69 @@
{/if}
</div>
{/if}
{#if fileAsk}
{#key fileAsk.anchor}
<form
use:positionFileAsk={fileAsk.anchor}
data-file-ask
class="fixed z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-2xl bg-white pt-3 pl-3 pr-2 pb-2 shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 dark:bg-gray-850 dark:text-white dark:ring-white/10"
aria-label={$i18n.t('Ask about this file')}
on:submit|preventDefault={submitFileAsk}
>
{#if fileAsk.text}
<blockquote
class="mb-2 border-l-2 border-gray-200 pl-2 text-xs leading-4 text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
<span class="line-clamp-3 whitespace-pre-wrap break-words">{fileAsk.text}</span>
</blockquote>
{/if}
<div class="flex items-center gap-1.5">
<textarea
id="file-ask-input"
rows="1"
class="min-w-0 flex-1 resize-none border-0 bg-transparent p-0 text-sm leading-5 text-gray-700 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-gray-300 dark:placeholder:text-gray-500"
placeholder={$i18n.t('Ask or edit…')}
aria-label={$i18n.t('Ask or edit…')}
bind:value={fileAskInput}
on:input={(event) => {
event.currentTarget.style.height = 'auto';
event.currentTarget.style.height = `${Math.min(96, event.currentTarget.scrollHeight)}px`;
}}
on:keydown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submitFileAsk();
}
}}
/>
<button
type="submit"
disabled={asking || !fileAskInput.trim()}
aria-label={$i18n.t('Send')}
class="flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full transition-colors {fileAskInput.trim()
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100'
: 'bg-gray-200 text-white dark:bg-gray-700 dark:text-gray-900'}"
>
{#if asking}<Spinner className="size-3.5" />{:else}<ArrowRight
className="size-3.5 -rotate-90"
/>{/if}
</button>
</div>
</form>
{/key}
{/if}
<ConfirmDialog
show={dirtyChoice !== null}
title={$i18n.t('Unsaved changes')}
confirmLabel={dirtyChoice?.sending ? $i18n.t('Save and send') : $i18n.t('Save')}
on:confirm={() => dirtyChoice?.resolve('save')}
on:cancel={() => dirtyChoice?.resolve('cancel')}
>
<p class="text-sm text-gray-500">{$i18n.t('Save your changes before continuing?')}</p>
{#if dirtyChoice && !dirtyChoice.sending}<button
type="button"
class="mt-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
on:click={() => dirtyChoice?.resolve('discard')}>{$i18n.t('Discard changes')}</button
>{/if}
</ConfirmDialog>

View file

@ -0,0 +1,404 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from 'svelte';
import type { WorkBook } from 'xlsx';
export let onReady = () => {};
export let currentSlide = 0;
let reportedReady = false;
export let initialSheet = '';
const reportReady = () => {
if (!loading && !previewError && !reportedReady) {
reportedReady = true;
onReady();
}
};
$: (loading, previewError, reportReady());
import { downloadFileBlob, downloadFilePreview } from '$lib/apis/terminal';
import { readOfficeComments } from '$lib/utils/comments/office';
import {
commentThreads,
type FileComment,
type CommentResult,
type CommentState
} from '$lib/utils/comments/types';
import FilePreview from './FilePreview.svelte';
import Icon from './Icon.svelte';
import DocxPreview from '$lib/components/common/DocxPreview.svelte';
import PdfPagesPreview from '$lib/components/common/PdfPagesPreview.svelte';
import PptxPreview from '$lib/components/common/PptxPreview.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n: any = getContext('i18n');
export let path: string;
export let baseUrl: string;
export let apiKey: string;
export let chatId: string | null = null;
export let targetPage: number | null = null;
let comments: FileComment[] = [];
let warnings: string[] = [];
let commentState: CommentState = 'loading';
let hoveredId: string | null = null;
let pinned = false;
let touchInteraction = false;
let overHighlight = false;
let popoverX = 0;
let popoverY = 0;
let closeTimer: ReturnType<typeof setTimeout>;
let original: ArrayBuffer | null = null;
let pdf: ArrayBuffer | null = null;
let loading = true;
let previewError = false;
let slides: string[] = [];
let workbook: WorkBook | null = null;
export let sheet = '';
let table: string | null = null;
let root: HTMLDivElement;
let popover: HTMLDivElement;
let alive = true;
let sheetToken = 0;
let anchoredIds: string[] = [];
const abort = new AbortController();
const ext = path.split('.').pop()!.toLowerCase();
$: threads = commentThreads(comments);
$: hoveredThreads =
hoveredId === '__unplaced'
? threads.filter((thread) => !anchoredIds.includes(thread[0].id))
: threads.filter((thread) => thread.some((c) => c.id === hoveredId));
$: unplaced = threads.map((thread) => thread[0]).filter((c) => !anchoredIds.includes(c.id));
const receiveAnchors = (ids: string[]) => {
if (ids.length !== anchoredIds.length || ids.some((id, i) => id !== anchoredIds[i]))
anchoredIds = ids;
};
const receiveComments = (result: CommentResult) => {
if (!alive) return;
comments = result.comments;
warnings = result.warnings;
commentState = warnings.length ? 'partial' : 'ready';
};
const setSheet = async (name: string) => {
if (!workbook?.Sheets[name]) return;
const token = ++sheetToken;
sheet = name;
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(workbook.Sheets[name]);
if (alive && token === sheetToken) {
table = result.html;
anchoredIds = comments
.filter((c) => c.location?.kind === 'cell' && c.location.sheet === sheet)
.map((c) => c.id);
}
};
const keepOpen = () => clearTimeout(closeTimer);
const closeLater = (event?: MouseEvent | FocusEvent) => {
keepOpen();
const next = event?.relatedTarget;
if (
next instanceof Element &&
(popover?.contains(next) ||
next.closest<HTMLElement>('[data-file-comment-id]')?.dataset.fileCommentId === hoveredId)
)
return;
if (!pinned) closeTimer = setTimeout(() => (hoveredId = null), 100);
};
const reveal = (event: MouseEvent | FocusEvent, pin = false) => {
overHighlight = false;
if (event instanceof MouseEvent && (event.buttons || window.getSelection()?.type === 'Range')) {
if (!pinned) {
keepOpen();
hoveredId = null;
}
return;
}
let element = (event.target as HTMLElement).closest<HTMLElement>(
'[data-file-comment-id],[data-cell]'
);
// Highlights are pointer-transparent so dragging selects the document text.
if (!element && event instanceof MouseEvent) {
const page = (event.target as HTMLElement).closest('.pdf-page-wrapper');
element =
Array.from((page ?? root).querySelectorAll<HTMLElement>('[data-comment-highlight]')).find(
(highlight) => {
const rect = highlight.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
) ?? null;
}
if (!element) {
closeLater();
return;
}
overHighlight = event instanceof MouseEvent && !!element.dataset.commentHighlight;
const id =
element.dataset.fileCommentId ??
comments.find(
(c) =>
c.location?.kind === 'cell' &&
c.location.sheet === sheet &&
c.location.cell === element.dataset.cell
)?.id;
if (!id) return;
keepOpen();
if (id === hoveredId) {
if (pin) pinned = true;
return;
}
hoveredId = id;
pinned = pin;
const rect = element.getBoundingClientRect();
popoverX = Math.max(8, Math.min(window.innerWidth - 296, rect.left));
popoverY = Math.max(8, Math.min(window.innerHeight - 260, rect.bottom + 10));
};
const displayDate = (value: string) => {
const pdf = /^D:(\d{4})(\d{2})(\d{2})/.exec(value);
const date = new Date(pdf ? `${pdf[1]}-${pdf[2]}-${pdf[3]}` : value);
return Number.isNaN(date.getTime())
? ''
: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
const portal = (node: HTMLElement) => {
document.body.appendChild(node);
const observer = new ResizeObserver(() => {
const rect = node.getBoundingClientRect();
popoverX = Math.max(8, Math.min(popoverX, window.innerWidth - rect.width - 8));
popoverY = Math.max(8, Math.min(popoverY, window.innerHeight - rect.height - 8));
});
observer.observe(node);
return {
destroy: () => {
observer.disconnect();
node.remove();
}
};
};
async function load() {
const previewPromise = ['docx', 'pptx'].includes(ext)
? downloadFilePreview(baseUrl, apiKey, path, chatId ?? undefined, abort.signal)
.then(async (r) => {
if (r) {
const bytes = await r.blob.arrayBuffer();
if (alive) {
pdf = bytes;
loading = false;
}
}
})
.catch(() => undefined)
: Promise.resolve();
try {
const raw = await downloadFileBlob(baseUrl, apiKey, path, chatId ?? undefined, abort.signal);
if (!raw) throw new Error('Original file unavailable');
const bytes = await raw.blob.arrayBuffer();
if (!alive) return;
original = bytes;
if (ext === 'pdf') {
pdf = bytes;
loading = false;
return;
}
if (ext === 'xlsx' || ext === 'xls') {
const XLSX = await import('xlsx');
if (!alive) return;
workbook = XLSX.read(bytes, { type: 'array' });
await setSheet(workbook.Sheets[initialSheet] ? initialSheet : workbook.SheetNames[0]);
loading = false;
}
void readOfficeComments(bytes, ext, workbook ?? undefined)
.then((result) => {
receiveComments(result);
if (workbook)
anchoredIds = comments
.filter((c) => c.location?.kind === 'cell' && c.location.sheet === sheet)
.map((c) => c.id);
})
.catch(() => {
if (alive) commentState = 'error';
});
await previewPromise;
if (!alive) return;
if (!pdf && ext === 'pptx') {
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(bytes);
if (alive) slides = result.images;
}
} catch {
if (alive) {
commentState = 'error';
previewError = true;
}
} finally {
await previewPromise;
if (alive) loading = false;
}
}
onMount(() => {
void load();
});
onDestroy(() => {
alive = false;
abort.abort();
clearTimeout(closeTimer);
});
</script>
<svelte:window
on:click={(event) => {
const target = event.target;
if (target instanceof Node && !root?.contains(target) && !popover?.contains(target)) {
keepOpen();
pinned = false;
hoveredId = null;
}
}}
on:keydown={(event) => {
if (event.key === 'Escape') {
pinned = false;
hoveredId = null;
}
}}
/>
<!-- Events are delegated from generated highlight buttons and focusable spreadsheet cells. -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events a11y_mouse_events_have_key_events -->
<div
bind:this={root}
class="relative flex-1 h-full min-h-0 min-w-0 overflow-hidden"
class:comment-hover={overHighlight}
on:mouseover={reveal}
on:mousemove={reveal}
on:focusin={reveal}
on:mouseout={closeLater}
on:focusout={closeLater}
on:pointerdown={(e) => {
touchInteraction = e.pointerType === 'touch';
if (!(e.target as HTMLElement).closest('[data-file-comment-id],[data-cell]')) {
keepOpen();
pinned = false;
hoveredId = null;
}
}}
on:click={(e) => reveal(e, touchInteraction || e.detail === 0)}
>
{#if loading}<div class="flex h-full items-center justify-center"><Spinner /></div>
{:else if pdf}
<PdfPagesPreview
bind:currentSlide
data={pdf}
{targetPage}
singlePage={ext === 'pptx'}
className="w-full h-full"
sourceComments={ext !== 'pdf' ? comments : null}
onAnchors={receiveAnchors}
onComments={ext === 'pdf' ? receiveComments : null}
onCommentsError={() => (commentState = 'error')}
/>
{:else if ext === 'docx' && original}
<DocxPreview
data={original}
{targetPage}
className="h-full"
reviewComments={comments}
onAnchors={receiveAnchors}
/>
{:else if ext === 'pptx' && slides.length}
<PptxPreview bind:currentSlide {slides} {targetPage} className="h-full" {comments} />
{:else if table !== null}
<FilePreview
selectedFile={path}
fileOfficeHtml={table}
excelSheetNames={workbook?.SheetNames ?? []}
selectedExcelSheet={sheet}
onSheetChange={setSheet}
/>
{:else if previewError}<p class="p-4 text-sm text-red-500">{$i18n.t('Preview failed')}</p>{/if}
{#if unplaced.length || commentState === 'error' || commentState === 'partial'}
<button
type="button"
data-file-comment-id="__unplaced"
class="absolute right-3 top-3 z-10 flex h-6 items-center gap-1 rounded px-1.5 text-xs text-gray-400 dark:text-gray-500 bg-white/95 dark:bg-gray-850/95 hover:text-gray-600 dark:hover:text-gray-300 focus-visible:outline focus-visible:outline-2"
aria-label={$i18n.t('Document comments')}
aria-haspopup="dialog"
><Icon name="chat-bubble" size={12} strokeWidth={1.5} />{unplaced.length || '!'}</button
>
{/if}
</div>
{#if hoveredId}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
use:portal
bind:this={popover}
role="dialog"
aria-label={$i18n.t('Comment')}
tabindex="-1"
class="file-comment-popover fixed z-[9999] w-72 max-w-[calc(100vw-1rem)] max-h-[50vh] overflow-y-auto rounded-2xl bg-white px-3 py-2 text-[0.8125rem] font-normal leading-5 text-gray-900 shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 dark:bg-gray-850 dark:text-gray-100 dark:ring-white/10"
style:left={`${popoverX}px`}
style:top={`${popoverY}px`}
on:mouseenter={keepOpen}
on:mouseleave={closeLater}
on:focusin={keepOpen}
on:focusout={closeLater}
>
<button
type="button"
class="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 focus-visible:outline focus-visible:outline-2"
aria-label={$i18n.t('Close comment')}
on:click={() => {
hoveredId = null;
pinned = false;
}}><Icon name="xmark" size={11} strokeWidth={1.5} /></button
>
{#if commentState === 'error'}<p>
{$i18n.t('Could not read comments. Download the original file to review them.')}
</p>{/if}
{#each warnings as warning}<p class="text-xs text-amber-700">{warning}</p>{/each}
{#each hoveredThreads as thread (thread[0].id)}
<div class="comment-thread">
{#if thread[0].status === 'resolved'}<span class="text-xs text-gray-500"
>{$i18n.t('Resolved')}</span
>{/if}
{#each thread as comment (comment.id)}
<div class="comment-message">
<div class="pr-6 text-xs font-medium text-gray-900 dark:text-gray-100">
{comment.author || $i18n.t('Unknown author')}
</div>
{#if comment.date}<div class="text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{displayDate(comment.date)}
</div>{/if}
<p class="mt-1.5 whitespace-pre-wrap break-words text-[0.8125rem] leading-5">
{comment.text || $i18n.t('Annotation without text')}
</p>
{#if hoveredId === '__unplaced'}<p class="mt-1 text-xs text-gray-500">
{comment.location?.kind === 'cell'
? `${comment.location.sheet}!${comment.location.cell}`
: comment.location?.kind === 'slide'
? `${$i18n.t('Slide')} ${comment.location.slide}`
: $i18n.t('Location unavailable')}
</p>{/if}
</div>
{/each}
</div>
{/each}
</div>
{/if}
<style>
.comment-hover,
.comment-hover :global(:hover) {
cursor: help;
}
.comment-message {
overflow-wrap: anywhere;
}
.comment-thread + .comment-thread,
.comment-message + .comment-message {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgb(0 0 0 / 5%);
}
:global(.dark) .comment-thread + .comment-thread,
:global(.dark) .comment-message + .comment-message {
border-color: rgb(255 255 255 / 10%);
}
</style>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import '$lib/utils/codemirror';
import { toast } from 'svelte-sonner';
import { basicSetup, EditorView } from 'codemirror';
import { keymap, placeholder as editorPlaceholder } from '@codemirror/view';
import { Compartment, EditorState } from '@codemirror/state';
@ -30,6 +31,15 @@
let internalValue = '';
let lastSearchTargetRequestId = 0;
export const getSelection = () => {
if (!editor || editor.state.selection.main.empty) return undefined;
const { from, to } = editor.state.selection.main;
return {
text: editor.state.sliceDoc(from, to),
location: `Lines ${editor.state.doc.lineAt(from).number}–${editor.state.doc.lineAt(to).number}`
};
};
/** Return the current editor content */
export const getValue = (): string => {
return editor?.state.doc.toString() ?? value;
@ -112,7 +122,7 @@
key: 'Mod-s',
run: () => {
if (onSave && !readOnly) {
onSave(editor?.state.doc.toString() ?? '');
void onSave(editor?.state.doc.toString() ?? '').catch((e) => toast.error(String(e)));
}
return true;
}

View file

@ -225,20 +225,20 @@
{:else if entry.type === 'directory'}
<button
type="button"
class="mr-1.5 flex w-5 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
class="mr-1.5 flex w-3 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
style="margin-left: {rowIndent};"
on:click|stopPropagation={() => onToggleExpand(directoryPath)}
aria-label={expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
>
<Icon
name={expanded ? 'chevron-down' : 'chevron-right'}
size={9}
size={12}
strokeWidth={1.5}
class={loadingChildren ? 'animate-pulse' : ''}
/>
</button>
{:else}
<span class="mr-1.5 w-5 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span>
<span class="mr-1.5 w-3 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span>
{/if}
{#if selectionMode || selected}
@ -258,9 +258,7 @@
<button
type="button"
class="flex min-w-0 flex-1 items-center py-1.5 pr-2 text-left {variant === 'workspace'
? 'gap-1.5'
: 'gap-2'}"
class="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 pr-2 text-left"
draggable={canMutate}
on:dragstart={(e) => {
if (!canMutate) {
@ -314,7 +312,7 @@
<Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={12} strokeWidth={1.5} />
{:else}<span class="w-3 shrink-0"></span>{/if}
{/if}
<FileTypeIcon name={entry.name} type={entry.type} size={variant === 'workspace' ? 14 : 12} />
<FileTypeIcon name={entry.name} type={entry.type} size={14} />
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<input

View file

@ -5,6 +5,8 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Icon from './Icon.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
export let dragType = 'application/x-terminal-file-move';
const i18n: any = getContext('i18n');
@ -245,28 +247,28 @@
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
on:click={() => {
onNewFolder();
actionsMenuOpen = false;
}}
disabled={!writable}
>
<Icon name="folder" size={12} strokeWidth={1.4} />
<span>{$i18n.t('New Folder')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
on:click={() => {
onNewFile();
actionsMenuOpen = false;
}}
disabled={!writable}
>
<Icon name="empty-page" size={12} strokeWidth={1.4} />
<Plus className="size-3.5" />
<span>{$i18n.t('New File')}</span>
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
on:click={() => {
onNewFolder();
actionsMenuOpen = false;
}}
disabled={!writable}
>
<Folder className="size-3.5" />
<span>{$i18n.t('New Folder')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"

View file

@ -1,11 +1,10 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import MarkdownDocument from './MarkdownDocument.svelte';
import { settings, config } from '$lib/stores';
import { injectCsp } from '$lib/utils/csp';
import { isCodeFile } from '$lib/utils/codeHighlight';
import { initMermaid, renderMermaidDiagram, sanitizeSvg } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils';
import Spinner from '../../common/Spinner.svelte';
import PdfPagesPreview from '../../common/PdfPagesPreview.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
@ -34,6 +33,28 @@
// Terminal connection for notebook execution
export let baseUrl: string = '';
export let apiKey: string = '';
export let chatId: string | null = null;
export let resourceVersion = 0;
export let onLocalLink: (path: string) => void = () => {};
export let dirty = false;
let codeValue = '';
let lastLoaded: string | null = null;
let bufferPath: string | null = null;
const syncContent = (content: string | null, path: string | null) => {
if (bufferPath !== path || codeValue === (lastLoaded ?? '')) codeValue = content ?? '';
bufferPath = path;
lastLoaded = content;
};
$: syncContent(fileContent, selectedFile);
$: dirty = editing ? editContent !== (fileContent ?? '') : codeValue !== (fileContent ?? '');
export const getSelection = () =>
editing && editTextarea?.selectionStart !== editTextarea?.selectionEnd
? {
text: editContent.slice(editTextarea.selectionStart, editTextarea.selectionEnd),
location: ''
}
: fileCodeEditorRef?.getSelection();
export const save = () => (editing ? saveEdit() : saveCodeFile());
// Office preview props
export let fileOfficeHtml: string | null = null;
@ -68,6 +89,7 @@
editing = false;
editContent = '';
saving = false;
codeValue = fileContent ?? '';
};
export const startEdit = async () => {
@ -82,11 +104,22 @@
export const saveEdit = async () => {
if (!onSave || readOnly) return;
saving = true;
await onSave(editContent);
saving = false;
editing = false;
const target = selectedFile,
content = editContent;
try {
await onSave(content);
if (selectedFile === target && editContent === content) editing = false;
} finally {
saving = false;
}
};
export const discard = () => {
codeValue = fileContent ?? '';
editContent = fileContent ?? '';
fileCodeEditorRef?.setValue(codeValue);
dirty = false;
};
export const cancelEdit = () => {
editing = false;
editContent = '';
@ -96,9 +129,12 @@
export const saveCodeFile = async () => {
if (!onSave || readOnly) return;
saving = true;
const content = fileCodeEditorRef?.getValue() ?? '';
await onSave(content);
saving = false;
const content = fileCodeEditorRef?.getValue() ?? codeValue;
try {
await onSave(content);
} finally {
saving = false;
}
};
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
@ -125,46 +161,6 @@
isHtml && selectedFile && baseUrl && baseUrl.includes('/api/v1/terminals/')
? `${baseUrl}/files/serve/${selectedFile.replace(/^\//, '')}`
: null;
$: renderedHtml =
isMarkdown && fileContent
? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string)
: '';
let markdownEl: HTMLDivElement;
let mermaidInstance: any = null;
const renderMermaidBlocks = async (el: HTMLDivElement) => {
if (!el) return;
const codeEls = el.querySelectorAll('code.language-mermaid');
if (codeEls.length === 0) return;
if (!mermaidInstance) {
mermaidInstance = await initMermaid();
}
for (const codeEl of codeEls) {
const pre = codeEl.parentElement;
if (!pre || pre.tagName !== 'PRE' || pre.dataset.mermaidRendered) continue;
pre.dataset.mermaidRendered = 'true';
try {
const svg = await renderMermaidDiagram(mermaidInstance, codeEl.textContent ?? '');
if (svg) {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram flex justify-center py-2';
wrapper.innerHTML = svg;
pre.replaceWith(wrapper);
}
} catch (e) {
console.error('Mermaid render error:', e);
}
}
};
$: if (renderedHtml && markdownEl) {
tick().then(() => renderMermaidBlocks(markdownEl));
}
// Simple CSV parser that handles quoted fields
const parseCsv = (text: string, delimiter: string): string[][] => {
const rows: string[][] = [];
@ -375,9 +371,9 @@
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
{searchTarget}
/>
</div>
@ -418,23 +414,34 @@
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
/>
</div>
{:else if isMarkdown && !showRaw}
<div bind:this={markdownEl} class="prose dark:prose-invert max-w-full text-sm p-3">
{@html renderedHtml}
</div>
{#key selectedFile}
<MarkdownDocument
content={fileContent}
onSave={readOnly ? null : onSave}
bind:saving
id={`file-${selectedFile}`}
path={selectedFile ?? ''}
{baseUrl}
{apiKey}
{chatId}
{resourceVersion}
{onLocalLink}
/>
{/key}
{:else if isMarkdown && showRaw}
<div class="absolute inset-0">
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
/>
</div>
{:else if isCsv && !showRaw && csvRows.length > 0}
@ -495,9 +502,9 @@
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
/>
</div>
{:else if isSvg && highlightedHtml && !showRaw}
@ -626,104 +633,6 @@
:global(.dark) .csv-row-num {
color: #6b7280;
}
/* ── Office preview styles ──────────────────────────────────────── */
:global(.office-preview) {
font-size: 0.875rem;
line-height: 1.6;
color: #1f2937;
background: #fff;
border-radius: 0.25rem;
}
:global(.dark .office-preview) {
color: #e5e7eb;
background: #1a1a2e;
}
:global(.office-preview table) {
border-collapse: collapse;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
line-height: 1.3;
}
:global(.office-preview table td),
:global(.office-preview table th) {
border: 1px solid rgba(200, 200, 200, 0.5);
padding: 0.25rem 0.625rem;
text-align: left;
white-space: nowrap;
user-select: text;
cursor: cell;
max-width: 18.75rem;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark .office-preview table td),
:global(.dark .office-preview table th) {
border-color: rgba(80, 80, 80, 0.5);
}
/* Column letter headers */
:global(.office-preview table th.excel-col-hdr) {
position: sticky;
top: 0;
z-index: 2;
background: #f0f0f0;
color: #666;
font-weight: 500;
font-size: 0.65rem;
text-align: center;
padding: 0.1875rem 0.625rem;
border-bottom: 2px solid rgba(180, 180, 180, 0.6);
}
:global(.dark .office-preview table th.excel-col-hdr) {
background: #2a2a3e;
color: #888;
border-bottom-color: rgba(100, 100, 100, 0.6);
}
/* Row number cells */
:global(.office-preview .excel-row-num) {
position: sticky;
left: 0;
z-index: 1;
background: #f0f0f0;
color: #999;
font-size: 0.6rem;
text-align: right !important;
padding: 0.25rem 0.5rem 0.25rem 0.25rem !important;
user-select: none;
width: 1px;
white-space: nowrap;
border-right: 2px solid rgba(180, 180, 180, 0.6) !important;
}
:global(.dark .office-preview .excel-row-num) {
background: #2a2a3e;
color: #666;
border-right-color: rgba(100, 100, 100, 0.6) !important;
}
/* Corner cell (intersection of row nums and col headers) */
:global(.office-preview thead .excel-row-num) {
z-index: 3;
}
/* Number cells right-aligned */
:global(.office-preview .excel-num) {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Row hover and selection */
:global(.office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) {
background: rgba(0, 0, 0, 0.015);
}
:global(.dark .office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) {
background: rgba(255, 255, 255, 0.02);
}
:global(.office-preview table tbody tr:hover td:not(.excel-row-num)) {
background: rgba(59, 130, 246, 0.06);
}
:global(.dark .office-preview table tbody tr:hover td:not(.excel-row-num)) {
background: rgba(59, 130, 246, 0.1);
}
:global(.office-preview table td:focus) {
outline: 2px solid rgba(59, 130, 246, 0.5);
outline-offset: -2px;
}
/* DOCX / generic office styles */
:global(.office-preview img) {
max-width: 100%;

View file

@ -0,0 +1,354 @@
<script lang="ts" context="module">
import { Marked } from 'marked';
import markedKatexExtension from '$lib/utils/marked/katex-extension';
import { footnoteExtension } from '$lib/utils/marked/footnote-extension';
// Documents must not inherit chat's global extensions or soft-line-break settings.
const documentMarkdown = new Marked(markedKatexExtension(), {
breaks: false,
extensions: [
{
...footnoteExtension(),
renderer: (token) => `<sup class="footnote-ref">${token.escapedText}</sup>`
}
]
});
</script>
<script lang="ts">
import { getAllContexts, mount, unmount, setContext, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import DOMPurify from 'dompurify';
import { v4 as uuidv4 } from 'uuid';
import { downloadFileBlob } from '$lib/apis/terminal';
import MarkdownTokens from '../Messages/Markdown/MarkdownTokens.svelte';
import type { Token, Tokens } from 'marked';
import { toast } from 'svelte-sonner';
export let content: string;
export let onSave: ((content: string) => Promise<void>) | null = null;
export let saving = false;
export let id: string;
export let path = '';
export let baseUrl = '';
export let apiKey = '';
export let chatId: string | null = null;
export let resourceVersion = 0;
export let onLocalLink: (path: string) => void = () => {};
/** Local document resources never become arbitrary network requests. */
function resolveDocumentPath(documentPath: string, reference: string): string | null {
if (!reference || reference.startsWith('#') || reference.startsWith('//')) return null;
if (/^[a-z][a-z\d+.-]*:/i.test(reference) && !/^[a-z]:[\\/]/i.test(reference)) return null;
let path: string;
try {
path = decodeURIComponent(reference.split(/[?#]/)[0]).replace(/\\/g, '/');
} catch {
return null;
}
if (/[\x00-\x1f]/.test(path)) return null;
const base = documentPath.replace(/\\/g, '/');
if (!path.startsWith('/') && !/^[a-z]:\//i.test(path))
path = base.slice(0, base.lastIndexOf('/') + 1) + path;
const prefix = /^[a-z]:\//i.test(path) ? path.slice(0, 3) : '/';
const parts: string[] = [];
for (const part of path.slice(prefix.length).split('/')) {
if (part === '..') parts.pop();
else if (part && part !== '.') parts.push(part);
}
return prefix + parts.join('/');
}
let generation = 0;
let urls = new Map<string, Promise<string | null>>();
let objectUrls: string[] = [];
const release = () => {
generation++;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls = [];
urls.clear();
};
onDestroy(release);
const resolveImage = (reference: string): Promise<string | null> => {
const file = resolveDocumentPath(path, reference);
if (!file || !baseUrl) return Promise.resolve(null);
if (!urls.has(file)) {
const current = generation;
urls.set(
file,
downloadFileBlob(baseUrl, apiKey, file, chatId ?? undefined).then((result) => {
if (!result || current !== generation) return null;
const url = URL.createObjectURL(result.blob);
objectUrls.push(url);
return url;
})
);
}
return urls.get(file)!;
};
const resources = {
resolveImage,
localPath: (ref: string) => (baseUrl ? resolveDocumentPath(path, ref) : null),
open: (ref: string) => {
const file = resolveDocumentPath(path, ref);
if (file) onLocalLink(file);
}
};
setContext('terminal-document', resources);
$: {
(resourceVersion, path, baseUrl, chatId);
release();
}
function markdownTasks(tokens: Token[]) {
const tasks: Tokens.ListItem[] = [];
documentMarkdown.walkTokens(tokens, (token) => {
if (token.type === 'list_item' && token.task) tasks.push(token as Tokens.ListItem);
});
return tasks;
}
function toggleMarkdownTask(content: string, index: number, checked: boolean) {
const tasks = markdownTasks(documentMarkdown.lexer(content));
if (!tasks[index]) throw new Error('Task no longer exists. Reload the file and try again.');
if (tasks[index].checked === checked) return content;
// Confirm the marker with the renderer's parser, so duplicates and code examples stay untouched.
for (const match of content.matchAll(/\[[ xX]\]/g)) {
const offset = match.index! + 1;
if ((content[offset].toLowerCase() === 'x') === checked) continue;
const updated = content.slice(0, offset) + (checked ? 'x' : ' ') + content.slice(offset + 1);
const next = markdownTasks(documentMarkdown.lexer(updated));
if (
next.length === tasks.length &&
next.every((task, i) => task.checked === (i === index ? checked : tasks[i].checked))
) {
return updated;
}
}
throw new Error('Could not locate the task in the file. Reload the file and try again.');
}
const taskEnabled = writable(false);
$: taskEnabled.set(!!onSave);
setContext('markdown', {
document: true,
taskEnabled,
onTaskClick: async ({
item,
checked,
input
}: {
item: Tokens.ListItem;
checked: boolean;
input: HTMLInputElement;
}) => {
input.checked = !!item.checked;
if (!onSave || saving) return;
const previous = content;
const wasChecked = !!item.checked;
saving = true;
try {
const index = markdownTasks(tokens).indexOf(item);
const updated = toggleMarkdownTask(content, index, checked);
// Keep the rendered document in place while saving this single marker.
parsedContent = content = updated;
item.checked = input.checked = checked;
await onSave(updated);
} catch (error) {
parsedContent = content = previous;
item.checked = input.checked = wasChecked;
toast.error(String(error));
} finally {
saving = false;
}
},
allowRemoteContent: true,
lexer: (source: string) => documentMarkdown.lexer(source),
parseInline: (tokens: Token[]) =>
documentMarkdown.Parser.parseInline(tokens, documentMarkdown.defaults),
renderHtml
});
const context = getAllContexts();
function renderHtml(node: HTMLElement, props: { id: string; tokens: Token[]; top: boolean }) {
let components: ReturnType<typeof mount>[] = [];
const destroy = () => {
components.forEach((component) => unmount(component));
components = [];
};
const update = ({ id, tokens, top }: typeof props) => {
destroy();
const prefix = `markdown-${uuidv4()}`;
// Parse HTML together; keep Markdown components in slots the document cannot forge.
node.replaceChildren(
DOMPurify.sanitize(
tokens
.map((token, index) =>
token.type === 'html'
? token.text
: `<div id="${prefix}-${index}" style="display: contents"></div>`
)
.join(''),
{ RETURN_DOM_FRAGMENT: true }
)
);
tokens.forEach((token, index) => {
const target = node.querySelector(`#${prefix}-${index}`);
if (target) {
components.push(
mount(MarkdownTokens, {
target,
context,
props: { id: `${id}-${index}`, tokens: [token], top, editCodeBlock: false }
})
);
}
});
};
update(props);
return { update, destroy };
}
let parsedContent: string | undefined;
let tokens: Token[] = [];
$: if (content !== parsedContent) {
tokens = documentMarkdown.lexer(content);
parsedContent = content;
}
const resolveHtmlImages = (node: HTMLElement) => {
let alive = true;
const update = () => {
for (const img of node.querySelectorAll<HTMLImageElement>(
'img:not([data-terminal-resolved])'
)) {
const src = img.getAttribute('src') ?? '';
if (!resources.localPath(src)) continue;
img.dataset.terminalResolved = 'true';
img.removeAttribute('src');
void resolveImage(src).then((url) => {
if (alive && url) img.src = url;
});
}
};
const observer = new MutationObserver(update);
observer.observe(node, { childList: true, subtree: true });
update();
return {
destroy: () => {
alive = false;
observer.disconnect();
}
};
};
const openLink = (event: MouseEvent) => {
const anchor = (event.target as Element)?.closest('a');
const href = anchor?.getAttribute('href');
if (href && resources.localPath(href)) {
event.preventDefault();
event.stopPropagation();
resources.open(href);
}
};
</script>
{#key resourceVersion}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
use:resolveHtmlImages
on:click|capture={openLink}
class="document-markdown prose prose-sm dark:prose-invert min-w-0 max-w-none p-3"
>
<MarkdownTokens {id} {tokens} editCodeBlock={false} />
</div>
{/key}
<style>
.document-markdown {
font-size: 0.75rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.document-markdown :global(h1),
.document-markdown :global(h2),
.document-markdown :global(h3),
.document-markdown :global(h4),
.document-markdown :global(h5),
.document-markdown :global(h6) {
margin: 1rem 0 0.375rem;
font-weight: 600;
line-height: 1.3;
}
.document-markdown :global(h1) {
font-size: 1.125rem;
}
.document-markdown :global(h2) {
font-size: 1rem;
}
.document-markdown :global(h3) {
font-size: 0.875rem;
}
.document-markdown :global(h4),
.document-markdown :global(h5),
.document-markdown :global(h6) {
font-size: 0.75rem;
}
.document-markdown :global(p),
.document-markdown :global(ul),
.document-markdown :global(ol) {
margin: 0.5rem 0;
}
.document-markdown :global(li) {
margin-block: 0.125rem;
}
.document-markdown :global(blockquote) {
font-size: 0.75rem;
}
.document-markdown :global(strong) {
font-weight: 600;
}
.document-markdown :global(hr) {
margin: 0.75rem 0;
}
.document-markdown :global(img) {
margin: 0.5rem 0;
}
.document-markdown :global(table) {
font-size: 0.75rem;
margin: 0.5rem 0;
}
.document-markdown :global(th),
.document-markdown :global(td) {
padding: 0.375rem 0.5rem !important;
}
.document-markdown :global(pre) {
margin: 0.5rem 0;
font-family: var(--font-mono);
}
.document-markdown :global(pre.hljs) {
padding: 0.5rem 0.75rem;
}
.document-markdown :global(pre code) {
font-size: 0.75rem;
}
.document-markdown :global(.codespan) {
font-size: 0.95em;
}
.document-markdown :global(input[type='checkbox']:disabled) {
cursor: default;
}
.document-markdown :global(.document-code-block) {
border-radius: 0.5rem;
}
.document-markdown :global(.document-code-block > .sticky) {
padding: 0.25rem 0.5rem;
}
.document-markdown :global(.document-code-block > .sticky button) {
height: 1.5rem;
padding-inline: 0.375rem;
font-size: 0.6875rem;
}
.document-markdown > :global(:first-child) {
margin-top: 0;
}
.document-markdown > :global(:last-child) {
margin-bottom: 0;
}
</style>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import type { TerminalContext } from '$lib/types';
import { resolveLocalizedFunction } from '$lib/utils/localizedContent';
import { functions as localizedFunctions } from '$lib/stores';
import DOMPurify from 'dompurify';
@ -151,6 +152,7 @@
export let statusHandler: Function = () => {};
export let forkHandler: Function = () => {};
export let chatId = '';
export let terminalContext: TerminalContext | null = null;
export let contextUsage = null;
export let contextCompactionEnabled = false;
export let embedded = false;
@ -226,7 +228,7 @@
let showTerminalMenu = false;
export let messageQueue: { id: string; prompt: string; files: any[] }[] = [];
export let messageQueue: { id: string; prompt: string; files: any[]; error?: string }[] = [];
export let onQueueSendNow: (id: string) => void = () => {};
export let onQueueEdit: (id: string) => void = () => {};
export let onQueueDelete: (id: string) => void = () => {};
@ -1012,21 +1014,27 @@
files = [...files, fileItem];
if (filesystemUploadTerminal) {
fileItem.terminal_id = $selectedTerminalId;
fileItem.session_id = chatId || null;
try {
const cwd =
(terminalContext?.terminalId === $selectedTerminalId
? terminalContext?.currentDirectory
: null) ||
(
await getCwd(
filesystemUploadTerminal.url,
filesystemUploadTerminal.key,
chatId || undefined
fileItem.session_id || undefined
)
)?.cwd || '/';
)?.cwd;
if (!cwd) throw new Error($i18n.t('Could not determine the upload folder.'));
const uploadedFile = await uploadNewFileToTerminal(
filesystemUploadTerminal.url,
filesystemUploadTerminal.key,
cwd,
file,
chatId || undefined
fileItem.session_id || undefined
);
if (uploadedFile) {
@ -1464,6 +1472,7 @@
files = [
...files,
{
...data,
type: 'filesystem',
id: path,
path,
@ -1906,6 +1915,7 @@
<QueuedMessageItem
id={queuedMessage.id}
content={queuedMessage.prompt}
error={queuedMessage.error ?? ''}
files={queuedMessage.files}
onSendNow={onQueueSendNow}
onEdit={onQueueEdit}
@ -2272,7 +2282,10 @@
}
}
if (e.key === 'Escape') {
if (
$settings?.keyboardShortcuts !== false &&
matchKeybinding(e) === Shortcut.CLEAR_INPUT_SELECTIONS
) {
console.log('Escape');
atSelectedModel = undefined;
selectedToolIds = [];

View file

@ -13,7 +13,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getFolders } from '$lib/apis/folders';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { searchFiles } from '$lib/apis/terminal';
import { searchFiles, filesystemFile } from '$lib/apis/terminal';
import { decodeString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
import {
resolveLocalizedModelDescription,
@ -107,6 +107,8 @@
return;
}
const terminalId = $selectedTerminalId;
const sessionId = $activeChatId;
const res = await searchFiles(
terminal.url,
terminal.key,
@ -114,19 +116,15 @@
'.',
20,
'any',
$activeChatId || undefined,
sessionId || undefined,
localStorage.getItem('fileNav:showHidden') === 'true'
).catch(() => null);
if (terminalId !== $selectedTerminalId || sessionId !== $activeChatId) return;
filesystemItems = (res?.results ?? []).map((item: any) => ({
...item,
type: 'filesystem',
...filesystemFile(item, terminalId!, sessionId || null),
filesystem_type: item.type,
id: item.path,
url: item.path,
name: item.name,
description: item.path,
status: 'processed'
description: item.path
}));
};

View file

@ -2,7 +2,6 @@
import { createEventDispatcher, getContext } from 'svelte';
import type { ModelControl } from '$lib/apis';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import QuestionMarkCircle from '$lib/components/icons/QuestionMarkCircle.svelte';
const i18n: any = getContext('i18n');
const dispatch = createEventDispatcher<{ change: string }>();
@ -22,19 +21,19 @@
<div class="space-y-2 px-2 py-2 text-xs">
<div class="flex items-center gap-2">
<span class="text-gray-700 dark:text-gray-100">{control.label}</span>
<span class="min-w-0 flex-1 truncate text-gray-500 dark:text-gray-400">{label}</span>
{#if control.description}
<Tooltip content={control.description}>
<Tooltip as="span" content={control.description} allowHTML={false}>
<button
type="button"
aria-label={control.description}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
class="cursor-help text-gray-700 underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:text-gray-100 dark:decoration-gray-500"
>
<QuestionMarkCircle className="size-3.5" />
{control.label}
</button>
</Tooltip>
{:else}
<span class="text-gray-700 dark:text-gray-100">{control.label}</span>
{/if}
<span class="min-w-0 flex-1 truncate text-gray-500 dark:text-gray-400">{label}</span>
</div>
<div class="space-y-1">
<div
@ -59,7 +58,7 @@
{disabled}
aria-label={control.label}
aria-valuetext={label}
class="control-slider h-5 w-full cursor-pointer appearance-none rounded-md bg-gray-100 outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-gray-400 disabled:cursor-wait [--slider-fill:var(--color-gray-300)] dark:bg-gray-800 dark:[--slider-fill:var(--color-gray-600)]"
class="control-slider h-5 w-full border-y border-transparent bg-clip-padding cursor-pointer appearance-none rounded-md bg-gray-100 outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-gray-400 disabled:cursor-wait [--slider-fill:var(--color-gray-500)] dark:bg-gray-800 dark:[--slider-fill:var(--color-gray-600)]"
style:background-image={`linear-gradient(to right, var(--slider-fill) ${progress}%, transparent ${progress}%)`}
on:input={() => (editing = true)}
on:change={() => {
@ -87,17 +86,17 @@
<style>
.control-slider::-webkit-slider-thumb {
appearance: none;
width: 1rem;
height: 1.25rem;
border-radius: 0.375rem;
width: 0.9375rem;
height: 1.1875rem;
border-radius: 0.25rem;
background: white;
box-shadow: 0 1px 3px #0003;
}
.control-slider::-moz-range-thumb {
width: 1rem;
height: 1.25rem;
width: 0.9375rem;
height: 1.1875rem;
border: 0;
border-radius: 0.375rem;
border-radius: 0.25rem;
background: white;
box-shadow: 0 1px 3px #0003;
}

View file

@ -161,6 +161,19 @@
}
};
export const scrollToMessage = async (id: string) => {
messagesCount = null;
buildMessages();
await tick();
const element = getMessagesContainer()?.querySelector<HTMLElement>(
`#${CSS.escape(`message-${id}`)}`
);
element?.scrollIntoView({ block: 'center' });
element?.animate([{ backgroundColor: '#a5a5c544' }, { backgroundColor: 'transparent' }], {
duration: 2200
});
};
export const scrollToTop = async () => {
messagesCount = null;
buildMessages();

View file

@ -30,6 +30,9 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
const markdown = getContext<{ allowRemoteContent: boolean; document?: boolean } | undefined>(
'markdown'
);
export let id = '';
export let edit = true;
@ -368,7 +371,11 @@
const render = async () => {
onUpdate(token, id);
if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) {
if (markdown?.allowRemoteContent === false) return;
if (
lang === 'mermaid' &&
(markdown?.document || (token?.raw ?? '').slice(-4).includes('```'))
) {
try {
renderHTML = await renderMermaid(code);
} catch (error) {
@ -379,7 +386,7 @@
}
} else if (
(lang === 'vega' || lang === 'vega-lite') &&
(token?.raw ?? '').slice(-4).includes('```')
(markdown?.document || (token?.raw ?? '').slice(-4).includes('```'))
) {
try {
renderHTML = await renderVegaVisualization(code, lang);
@ -440,7 +447,7 @@
class="relative {className} flex flex-col rounded-2xl border border-gray-100/30 dark:border-gray-850/30 my-0.5 overflow-clip"
dir="ltr"
>
{#if ['mermaid', 'vega', 'vega-lite'].includes(lang)}
{#if markdown?.allowRemoteContent !== false && ['mermaid', 'vega', 'vega-lite'].includes(lang)}
{#if renderHTML}
<SvgPanZoom
className=" rounded-2xl max-h-fit overflow-hidden"
@ -506,7 +513,7 @@
>
{$i18n.t('Running')}
</div>
{:else if run}
{:else if run && markdown?.allowRemoteContent !== false}
<button
class="run-code-button flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-normal text-gray-600 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-gray-850 dark:hover:text-white"
on:click={async () => {
@ -524,7 +531,7 @@
{#if save}
<button
class="save-code-button flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
class="save-code-button flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-normal text-gray-600 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-gray-850 dark:hover:text-white"
on:click={saveCode}
>
{saved ? $i18n.t('Saved') : $i18n.t('Save')}

View file

@ -31,7 +31,7 @@
</script>
<script>
import { onDestroy } from 'svelte';
import { onDestroy, setContext } from 'svelte';
import { replaceTokens, processResponseContent } from '$lib/utils';
import { user } from '$lib/stores';
@ -52,6 +52,13 @@
export let topPadding = false;
export let allowEmbeds = false;
export let allowHtml = true;
export let allowRemoteContent = true;
setContext('markdown', {
get allowRemoteContent() {
return allowRemoteContent;
}
});
export let sourceIds = [];
@ -78,9 +85,18 @@
lastParsedContent = processed;
tokens = marked.lexer(processed);
if (!allowHtml) {
if (!allowHtml || !allowRemoteContent) {
marked.walkTokens(tokens, (token) => {
if (token.type === 'html') Object.assign(token, { type: 'text' });
if (
(!allowHtml && token.type === 'html') ||
(!allowRemoteContent &&
['html', 'image', 'iframe', 'mention', 'details', 'colonFence'].includes(token.type))
) {
const raw = token.raw;
// Drop child tokens too, so blocked content cannot render through a nested renderer.
for (const key of Object.keys(token)) Reflect.deleteProperty(token, key);
Object.assign(token, { type: 'text', raw, text: raw });
}
});
}
};

View file

@ -43,7 +43,10 @@
}
};
export function alertComponent(token: Token): AlertData | false {
export function alertComponent(
token: Token,
lexer: (source: string) => Token[] = marked.lexer
): AlertData | false {
const regExpStr = `^(?:\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\])\\s*?\n*`;
const regExp = new RegExp(regExpStr);
const matches = token.text?.match(regExp);
@ -51,7 +54,7 @@
if (matches && matches.length) {
const alertType = matches[1] as AlertType;
const newText = token.text.replace(regExp, '');
const newTokens = marked.lexer(newText);
const newTokens = lexer(newText);
return {
type: alertType,
text: newText,

View file

@ -7,6 +7,7 @@
import { settings } from '$lib/stores';
const i18n: any = getContext('i18n');
const markdown = getContext<{ document?: boolean } | undefined>('markdown');
export let id: string;
export let token: Token;
@ -18,7 +19,9 @@
</script>
{#if token.type === 'html'}
{#if html && html.includes('<video')}
{#if markdown?.document}
{@html html ?? ''}
{:else if html && html.includes('<video')}
{@const video = html.match(/<video[^>]*>([\s\S]*?)<\/video>/)}
{@const videoSrc = video && video[1]}
{#if videoSrc}

View file

@ -7,6 +7,17 @@
import { goto } from '$app/navigation';
const i18n = getContext('i18n');
const terminalDocument = getContext<
| {
localPath: (ref: string) => string | null;
resolveImage: (ref: string) => Promise<string | null>;
}
| undefined
>('terminal-document');
const markdown = getContext<
| { allowRemoteContent: boolean; document?: boolean; parseInline?: (tokens: Token[]) => string }
| undefined
>('markdown');
import { WEBUI_BASE_URL } from '$lib/constants';
import { skills, terminalSkills } from '$lib/stores';
@ -81,16 +92,24 @@
// Invalid URL, let browser handle it
}
};
$: inlineHtml =
markdown?.document && tokens.some((token) => token.type === 'html')
? DOMPurify.sanitize(markdown.parseInline?.(tokens) ?? '')
: null;
</script>
{#each tokens as token, tokenIdx (tokenIdx)}
<!-- Keep paired inline HTML tags together; sanitizing individual tags loses their contents. -->
{#if inlineHtml !== null}
{@html inlineHtml}
{/if}
{#each inlineHtml === null ? tokens : [] as token, tokenIdx (tokenIdx)}
{#if token.type === 'escape'}
{unescapeHtml(token.text)}
{:else if token.type === 'html'}
<HtmlToken {id} {token} {onSourceClick} />
{:else if token.type === 'link'}
{@const linkToken = getLinkToken(token)}
{#if linkToken.noteId}
{#if linkToken.noteId && !markdown?.document && markdown?.allowRemoteContent !== false}
<NoteLinkToken noteId={linkToken.noteId} href={linkToken.href} />
{:else if token.tokens && !linkToken.isAutolink}
<a
@ -112,7 +131,15 @@
>
{/if}
{:else if token.type === 'image'}
<Image src={token.href} alt={token.text} allowExternal={true} />
{#if terminalDocument?.localPath(token.href)}
{#await terminalDocument.resolveImage(token.href)}
<span class="text-xs text-gray-400">{token.text}</span>
{:then url}
{#if url}<img src={url} alt={token.text} data-terminal-resolved="true" />{:else}<span
class="text-xs text-gray-400">Image unavailable: {token.text}</span
>{/if}
{/await}
{:else}<Image src={token.href} alt={token.text} allowExternal={true} />{/if}
{:else if token.type === 'strong'}
<strong
><svelte:self id={`${id}-strong`} tokens={token.tokens} {sourceIds} {onSourceClick} /></strong
@ -161,6 +188,10 @@
<TextToken {token} {done} />
{/if}
{:else if token.type === 'text'}
<TextToken {token} {done} />
{#if markdown?.document}
{unescapeHtml(token.text)}
{:else}
<TextToken {token} {done} />
{/if}
{/if}
{/each}

View file

@ -1,12 +1,30 @@
<script lang="ts">
import { decode } from 'html-entities';
import { onMount, getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import type { Action } from 'svelte/action';
const i18n = getContext('i18n');
const markdown = getContext<
| {
document?: boolean;
taskEnabled?: Readable<boolean>;
onTaskClick?: (event: {
item: Tokens.ListItem;
checked: boolean;
input: HTMLInputElement;
}) => void;
lexer?: (source: string) => Token[];
renderHtml?: Action<HTMLElement, { id: string; tokens: Token[]; top: boolean }>;
}
| undefined
>('markdown');
const taskEnabled = markdown?.taskEnabled;
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { type Token } from 'marked';
import { type Token, type Tokens } from 'marked';
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import { resolveChatMessageToolCall } from '$lib/apis/chats';
@ -132,13 +150,12 @@
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
$: displayTokens = getDisplayTokens(tokens);
$: renderHtml = tokens.some((token) => token.type === 'html') ? markdown?.renderHtml : undefined;
$: singlePlainBlock =
displayTokens.length === 1 &&
(displayTokens[0]?.type === 'paragraph' || displayTokens[0]?.type === 'text');
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
console.log('Exporting table to CSV');
// Extract header row text, decode HTML entities, and escape for CSV.
const header = token.header.map(
(headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"`
@ -160,10 +177,6 @@
// Join the rows using commas (,) as the separator and rows using newline (\n).
const csvContent = csvData.map((row) => row.join(',')).join('\n');
// Log rows and CSV content to ensure everything is correct.
console.log(csvData);
console.log(csvContent);
// To handle Unicode characters, you need to prefix the data with a BOM:
const bom = '\uFEFF'; // BOM for UTF-8
@ -176,7 +189,10 @@
</script>
<!-- {JSON.stringify(tokens)} -->
{#each displayTokens as token, tokenIdx (tokenIdx)}
{#if renderHtml}
<div class="contents" use:renderHtml={{ id, tokens, top }}></div>
{/if}
{#each renderHtml ? [] : displayTokens as token, tokenIdx (tokenIdx)}
{#if token.type === 'hr'}
<hr class="border-gray-50 dark:border-gray-850/30" />
{:else if token.type === 'heading'}
@ -190,17 +206,19 @@
/>
</svelte:element>
{:else if token.type === 'code'}
{#if token.codeBlockStyle !== 'indented'}
{#if markdown?.document || token.codeBlockStyle !== 'indented'}
<CodeBlock
id={`${id}-${tokenIdx}`}
collapsed={$settings?.collapseCodeBlocks ?? false}
className={markdown?.document ? 'document-code-block' : ''}
collapsed={markdown?.document ? false : ($settings?.collapseCodeBlocks ?? false)}
{token}
lang={token?.lang ?? ''}
code={token?.text ?? ''}
{attributes}
{save}
{preview}
edit={editCodeBlock}
save={save && !markdown?.document}
preview={preview && !markdown?.document}
run={!markdown?.document}
edit={editCodeBlock && !markdown?.document}
stickyButtonsClassName={topPadding ? 'top-10' : 'top-0'}
onSave={(value) => {
onSave({
@ -301,7 +319,7 @@
</div>
</div>
{:else if token.type === 'blockquote'}
{@const alert = alertComponent(token)}
{@const alert = alertComponent(token, markdown?.lexer)}
{#if alert}
<AlertRenderer {token} {alert} {allowEmbeds} />
{:else}
@ -328,20 +346,22 @@
{#if token.ordered}
<ol start={token.start || 1} dir="auto">
{#each token.items as item, itemIdx}
<li class="text-start">
<li dir="auto" class="text-start">
{#if item?.task}
<input
class=" translate-y-[1px] -translate-x-1 flex-shrink-0"
class="task-checkbox translate-y-[1px] -translate-x-1 [&:dir(rtl)]:translate-x-1 flex-shrink-0"
type="checkbox"
disabled={markdown?.document && !$taskEnabled}
checked={item.checked}
on:change={(e) => {
onTaskClick({
(markdown?.onTaskClick ?? onTaskClick)({
id: id,
token: token,
tokenIdx: tokenIdx,
item: item,
itemIdx: itemIdx,
checked: e.target.checked
input: e.currentTarget,
checked: e.currentTarget.checked
});
}}
/>
@ -352,7 +372,7 @@
{chatId}
{messageId}
tokens={item.tokens}
top={token.loose}
top={item.task || token.loose}
{done}
{allowEmbeds}
{save}
@ -369,31 +389,33 @@
{:else}
<ul dir="auto" class="">
{#each token.items as item, itemIdx}
<li class="text-start {item?.task ? 'flex -translate-x-6.5 gap-3 ' : ''}">
<li dir="auto" class="text-start {item?.task ? 'relative list-none' : ''}">
{#if item?.task}
<input
class="flex-shrink-0"
class="task-checkbox"
type="checkbox"
disabled={markdown?.document && !$taskEnabled}
checked={item.checked}
on:change={(e) => {
onTaskClick({
(markdown?.onTaskClick ?? onTaskClick)({
id: id,
token: token,
tokenIdx: tokenIdx,
item: item,
itemIdx: itemIdx,
checked: e.target.checked
input: e.currentTarget,
checked: e.currentTarget.checked
});
}}
/>
<div>
<div class="task-content">
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
{chatId}
{messageId}
tokens={item.tokens}
top={token.loose}
top={item.task || token.loose}
{done}
{allowEmbeds}
{save}
@ -411,7 +433,7 @@
{chatId}
{messageId}
tokens={item.tokens}
top={token.loose}
top={item.task || token.loose}
{done}
{allowEmbeds}
{save}
@ -646,3 +668,39 @@
{console.log('Unknown token', token)}
{/if}
{/each}
<style>
/* Reserve marker space on each item's start side, independently of the list's direction. */
ul,
ol {
padding-inline: 0;
}
li {
margin-inline-start: 1.5em;
}
ul > li > input.task-checkbox {
position: absolute;
inset-inline-start: -1.5em;
top: calc((1lh - 0.875rem) / 2);
margin: 0;
}
input.task-checkbox::after {
inset: 0;
width: auto;
height: auto;
background-size: 100% 100%;
}
input.task-checkbox:checked ~ :global(p),
input.task-checkbox:checked ~ .task-content > :global(p) {
opacity: 0.55;
text-decoration-line: line-through;
}
.task-content > :global(p:first-child) {
margin-top: 0;
}
</style>

View file

@ -50,6 +50,10 @@
export let topPadding = false;
export let onInsertToNote: ((content: string) => void) | null = null;
// Keep the latest turn's real height when assigning a chat ID remounts the message list.
$: isCurrentTurn =
messageId === history.currentId || messageId === history.messages[history.currentId]?.parentId;
// WebKit's content-visibility implementation has paint bugs that leave
// on-screen messages blank (#26712), so skip virtualization there
// iOS WebViews and home screen apps carry no Safari token, hence the vendor check
@ -63,7 +67,7 @@
class="flex flex-col justify-between px-3.5 mb-3 w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-[58rem]'} mx-auto rounded-lg group message-listitem"
class:message-virtualized={!isWebKit}
class:message-virtualized={!isWebKit && !isCurrentTurn}
>
{#if history.messages[messageId]}
{#if history.messages[messageId].role === 'user'}
@ -85,6 +89,7 @@
{deleteMessage}
{allowDelete}
{readOnly}
{shareMode}
{compactPreview}
{editCodeBlock}
{topPadding}

View file

@ -1,4 +1,8 @@
<script lang="ts">
import EditPencilIcon from '$lib/components/layout/Sidebar/icons/EditPencil.svelte';
import CopyIcon from '$lib/components/layout/Sidebar/icons/Copy.svelte';
import TrashIcon from '$lib/components/layout/Sidebar/icons/Trash.svelte';
import ChevronRightIcon from '$lib/components/layout/Sidebar/icons/ChevronRight.svelte';
import { resolveLocalizedFunction } from '$lib/utils/localizedContent';
import { functions as localizedFunctions } from '$lib/stores';
import { toast } from 'svelte-sonner';
@ -48,8 +52,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import RateComment from './RateComment.svelte';
import WebSearchResults from './ResponseMessage/WebSearchResults.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import ArrowUpLeft from '$lib/components/icons/ArrowUpLeft.svelte';
import Sparkles from '$lib/components/layout/Sidebar/icons/Sparkles.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@ -289,7 +292,10 @@
} else {
$audioQueue.setId(`${message.id}`);
$audioQueue.setPlaybackRate($settings.audio?.tts?.playbackRate ?? 1);
let synthesizing = true;
$audioQueue.onStopped = ({ event }) => {
if (event === 'empty-queue' && synthesizing) return;
speaking = false;
speakingIdx = undefined;
@ -368,6 +374,8 @@
}
}
}
synthesizing = false;
}
};
@ -952,7 +960,10 @@
{:else if !edit}
<div
bind:this={buttonsContainerElement}
class="flex items-center justify-start overflow-x-auto whitespace-nowrap buttons text-gray-600 dark:text-gray-500 mt-0.5 [&>*]:shrink-0"
class="flex items-center justify-start overflow-x-auto whitespace-nowrap buttons {($settings?.highContrastMode ??
false)
? 'text-gray-600 dark:text-gray-500'
: 'text-gray-600/80 dark:text-gray-500/70'} mt-0.5 [&>*]:shrink-0"
>
{#if message.done || siblings.length > 1}
{#if message.done && onInsertToNote && visibleResponseContent}
@ -961,12 +972,26 @@
aria-label={$i18n.t('Insert into note')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
on:click={() => {
onInsertToNote?.(visibleResponseContent);
}}
>
<ArrowUpLeft className="size-3.5" strokeWidth="2" />
<svg
class="size-4"
aria-hidden="true"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19L6 6M6 6L6 18.48M6 6L18.48 6"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</Tooltip>
{/if}
@ -975,31 +1000,17 @@
<div class="flex self-center min-w-fit" dir="ltr">
<button
aria-label={$i18n.t('Previous message')}
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
class="self-center inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-gray-400 hover:text-gray-600 rounded-md transition"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
<ChevronRightIcon className="size-4 rotate-180" />
</button>
{#if messageIndexEdit}
<div
class="text-sm flex justify-center font-normal self-center dark:text-gray-100 min-w-fit"
class="text-sm flex justify-center font-normal self-center dark:text-gray-500 min-w-fit"
>
<input
id="message-index-input-{message.id}"
@ -1020,13 +1031,13 @@
messageIndexEdit = false;
}
}}
class="bg-transparent font-normal self-center dark:text-gray-100 min-w-fit outline-hidden"
class="bg-transparent font-normal self-center dark:text-gray-500 min-w-fit outline-hidden"
/>/{siblings.length}
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-sm tracking-widest font-normal self-center dark:text-gray-100 min-w-fit"
class="text-sm tracking-widest font-normal self-center dark:text-gray-500 min-w-fit"
on:dblclick={async () => {
messageIndexEdit = true;
@ -1043,27 +1054,13 @@
{/if}
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
class="self-center inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-gray-400 hover:text-gray-600 rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
aria-label={$i18n.t('Next message')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
aria-hidden="true"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
<ChevronRightIcon className="size-4" />
</button>
</div>
{/if}
@ -1076,26 +1073,12 @@
aria-label={$i18n.t('Edit')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
on:click={() => {
editMessageHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
aria-hidden="true"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
<EditPencilIcon />
</button>
</Tooltip>
{/if}
@ -1106,26 +1089,12 @@
aria-label={$i18n.t('Copy')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition copy-response-button"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition copy-response-button"
on:click={() => {
copyToClipboard(visibleResponseContent);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
aria-hidden="true"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
<CopyIcon />
</button>
</Tooltip>
@ -1136,7 +1105,7 @@
id="speak-button-{message.id}"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
on:click={() => {
if (!loadingSpeech) {
if (speaking) {
@ -1149,67 +1118,70 @@
>
{#if loadingSpeech}
<svg
class=" w-4 h-4"
fill="currentColor"
viewBox="0 0 24 24"
class="size-4 animate-spin"
aria-hidden="true"
stroke-width="1.5"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_S1WN {
animation: spinner_MGfb 0.8s linear infinite;
animation-delay: -0.8s;
}
.spinner_Km9P {
animation-delay: -0.65s;
}
.spinner_JApP {
animation-delay: -0.5s;
}
@keyframes spinner_MGfb {
93.75%,
100% {
opacity: 0.2;
}
}
</style>
<circle class="spinner_S1WN" cx="4" cy="12" r="3" />
<circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3" />
<circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3" />
<path
d="M21.8883 13.5C21.1645 18.3113 17.013 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C16.1006 2 19.6248 4.46819 21.1679 8"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M17 8H21.4C21.7314 8 22 7.73137 22 7.4V3"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else if speaking}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="size-4"
aria-hidden="true"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 14L20.0005 12M22 10L20.0005 12M20.0005 12L18 10M20.0005 12L22 14"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
/>
<path
d="M2 13.8571V10.1429C2 9.03829 2.89543 8.14286 4 8.14286H6.9C7.09569 8.14286 7.28708 8.08544 7.45046 7.97772L13.4495 4.02228C14.1144 3.5839 15 4.06075 15 4.85714V19.1429C15 19.9392 14.1144 20.4161 13.4495 19.9777L7.45046 16.0223C7.28708 15.9146 7.09569 15.8571 6.9 15.8571H4C2.89543 15.8571 2 14.9617 2 13.8571Z"
stroke="currentColor"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="size-4"
aria-hidden="true"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 13.8571V10.1429C1 9.03829 1.89543 8.14286 3 8.14286H5.9C6.09569 8.14286 6.28708 8.08544 6.45046 7.97772L12.4495 4.02228C13.1144 3.5839 14 4.06075 14 4.85714V19.1429C14 19.9392 13.1144 20.4161 12.4495 19.9777L6.45046 16.0223C6.28708 15.9146 6.09569 15.8571 5.9 15.8571H3C1.89543 15.8571 1 14.9617 1 13.8571Z"
stroke="currentColor"
/>
<path
d="M17.5 7.5C17.5 7.5 19 9 19 11.5C19 14 17.5 15.5 17.5 15.5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M20.5 4.5C20.5 4.5 23 7 23 11.5C23 16 20.5 18.5 20.5 18.5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
/>
</svg>
{/if}
@ -1236,25 +1208,37 @@
aria-hidden="true"
class=" {isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition whitespace-pre-wrap"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition whitespace-pre-wrap"
on:click={() => {
console.log(message);
}}
id="info-{message.id}"
>
<svg
class="size-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke-width="1.5"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.5V16.5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 7.51L12.01 7.49889"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
</button>
@ -1267,26 +1251,49 @@
aria-label={$i18n.t('Fork chat')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
on:click={() => {
forkHandler?.(message.id);
}}
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4 -scale-x-100 -scale-y-100 -rotate-90"
aria-hidden="true"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 12H9" />
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
<path d="M17 4L20 7L17 10" />
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
<path d="M17 14L20 17L17 20" />
<path
d="M17 7C18.1046 7 19 6.10457 19 5C19 3.89543 18.1046 3 17 3C15.8954 3 15 3.89543 15 5C15 6.10457 15.8954 7 17 7Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M7 7C8.10457 7 9 6.10457 9 5C9 3.89543 8.10457 3 7 3C5.89543 3 5 3.89543 5 5C5 6.10457 5.89543 7 7 7Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M7 21C8.10457 21 9 20.1046 9 19C9 17.8954 8.10457 17 7 17C5.89543 17 5 17.8954 5 19C5 20.1046 5.89543 21 7 21Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M7 7V17"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M17 7V8C17 10.5 15 11 15 11L9 13C9 13 7 13.5 7 16V17"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</Tooltip>
@ -1299,11 +1306,11 @@
aria-label={$i18n.t('Good Response')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
message?.annotation?.rating ?? ''
).toString() === '1'
? 'bg-gray-100 dark:bg-gray-800'
: ''} dark:hover:text-white hover:text-black transition disabled:cursor-progress disabled:hover:bg-transparent"
: ''} dark:hover:text-gray-400 hover:text-gray-600 transition disabled:cursor-progress disabled:hover:bg-transparent"
disabled={feedbackLoading}
on:click={async (e) => {
e.stopPropagation();
@ -1316,18 +1323,23 @@
}}
>
<svg
class="size-4"
aria-hidden="true"
stroke="currentColor"
fill="none"
stroke-width="2.3"
stroke-width="1.5"
viewBox="0 0 24 24"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4 h-4"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
d="M16.4724 20H4.1C3.76863 20 3.5 19.7314 3.5 19.4V9.6C3.5 9.26863 3.76863 9 4.1 9H6.86762C7.57015 9 8.22116 8.6314 8.5826 8.02899L11.293 3.51161C11.8779 2.53688 13.2554 2.44422 13.9655 3.33186C14.3002 3.75025 14.4081 4.30635 14.2541 4.81956L13.2317 8.22759C13.1162 8.61256 13.4045 9 13.8064 9H18.3815C19.7002 9 20.658 10.254 20.311 11.5262L18.4019 18.5262C18.1646 19.3964 17.3743 20 16.4724 20Z"
stroke="currentColor"
stroke-linecap="round"
/>
<path
d="M7 20L7 9"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
@ -1338,11 +1350,11 @@
aria-label={$i18n.t('Bad Response')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
message?.annotation?.rating ?? ''
).toString() === '-1'
? 'bg-gray-100 dark:bg-gray-800'
: ''} dark:hover:text-white hover:text-black transition disabled:cursor-progress disabled:hover:bg-transparent"
: ''} dark:hover:text-gray-400 hover:text-gray-600 transition disabled:cursor-progress disabled:hover:bg-transparent"
disabled={feedbackLoading}
on:click={async (e) => {
e.stopPropagation();
@ -1355,18 +1367,23 @@
}}
>
<svg
class="size-4"
aria-hidden="true"
stroke="currentColor"
fill="none"
stroke-width="2.3"
stroke-width="1.5"
viewBox="0 0 24 24"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4 h-4"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"
d="M16.4724 3.5H4.1C3.76863 3.5 3.5 3.76863 3.5 4.1V13.9C3.5 14.2314 3.76863 14.5 4.1 14.5H6.86762C7.57015 14.5 8.22116 14.8686 8.5826 15.471L11.293 19.9884C11.8779 20.9631 13.2554 21.0558 13.9655 20.1681C14.3002 19.7497 14.4081 19.1937 14.2541 18.6804L13.2317 15.2724C13.1162 14.8874 13.4045 14.5 13.8064 14.5H18.3815C19.7002 14.5 20.658 13.246 20.311 11.9738L18.4019 4.97376C18.1646 4.10364 17.3743 3.5 16.4724 3.5Z"
stroke="currentColor"
stroke-linecap="round"
/>
<path
d="M7 14.5L7 3.5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
@ -1381,35 +1398,32 @@
id="continue-response-button"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
on:click={() => {
continueResponse();
continueResponse(message);
}}
>
<svg
class="size-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke-width="1.5"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.90588 4.53682C6.50592 4.2998 6 4.58808 6 5.05299V18.947C6 19.4119 6.50592 19.7002 6.90588 19.4632L18.629 12.5162C19.0211 12.2838 19.0211 11.7162 18.629 11.4838L6.90588 4.53682Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"
/>
</svg>
</button>
</Tooltip>
{/if}
{/if}
{#if (!readOnly && (!message.user_id || message.user_id === $user?.id)) || shareMode === 'continue'}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)}
{#if $settings?.regenerateMenu ?? true}
<button
@ -1457,21 +1471,39 @@
aria-label={$i18n.t('Regenerate')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
class="size-4"
aria-hidden="true"
stroke="currentColor"
class="w-4 h-4"
stroke-width="1.5"
viewBox="-2 -2 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.1679 8C19.6247 4.46819 16.1006 2 11.9999 2C6.81459 2 2.55104 5.94668 2.04932 11"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M17 8H21.4C21.7314 8 22 7.73137 22 7.4V3"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M2.88146 16C4.42458 19.5318 7.94874 22 12.0494 22C17.2347 22 21.4983 18.0533 22 13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M7.04932 16H2.64932C2.31795 16 2.04932 16.2686 2.04932 16.6V21"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
@ -1484,7 +1516,7 @@
aria-label={$i18n.t('Regenerate')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition regenerate-response-button"
on:click={() => {
showRateComment = false;
regenerateResponse(message);
@ -1503,25 +1535,45 @@
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
class="size-4"
aria-hidden="true"
stroke="currentColor"
class="w-4 h-4"
stroke-width="1.5"
viewBox="-2 -2 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.1679 8C19.6247 4.46819 16.1006 2 11.9999 2C6.81459 2 2.55104 5.94668 2.04932 11"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M17 8H21.4C21.7314 8 22 7.73137 22 7.4V3"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M2.88146 16C4.42458 19.5318 7.94874 22 12.0494 22C17.2347 22 21.4983 18.0533 22 13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M7.04932 16H2.64932C2.31795 16 2.04932 16.2686 2.04932 16.6V21"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{/if}
{/if}
{/if}
{#if (!readOnly && (!message.user_id || message.user_id === $user?.id)) || (shareMode === 'continue' && message.user_id === $user?.id)}
{#each (!readOnly && model?.actions) || [] as action}
<Tooltip
content={resolveLocalizedFunction(
@ -1540,7 +1592,7 @@
)}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
on:click={() => {
actionMessage(action.id, message);
}}
@ -1562,14 +1614,14 @@
/>
</div>
{:else}
<Sparkles strokeWidth="2.1" className="size-4" />
<Sparkles />
{/if}
</button>
</Tooltip>
{/each}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? true)}
{#if siblings.length > 1}
{#if (!readOnly || ($user?.role === 'admin' && $config?.features?.enable_admin_chat_access)) && siblings.length > 1}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
type="button"
@ -1577,7 +1629,7 @@
id="delete-response-button"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition disabled:opacity-50 disabled:hover:bg-transparent"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition disabled:opacity-50 disabled:hover:bg-transparent"
disabled={!allowDelete}
on:click={(e) => {
if (!allowDelete) {
@ -1590,21 +1642,7 @@
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<TrashIcon />
</button>
</Tooltip>
{/if}

View file

@ -39,7 +39,7 @@
}}
>
<div class="flex items-start gap-2">
<StatusItem {status} />
<StatusItem {status} clamp={!showHistory || history.length > 1} />
</div>
</button>
@ -64,7 +64,7 @@
{/if}
</div>
<StatusItem {status} done={true} />
<StatusItem {status} done={true} clamp={false} />
</div>
{/each}
</div>

View file

@ -7,6 +7,7 @@
export let status = null;
export let done = false;
export let clamp = true;
</script>
{#if !status?.hidden}
@ -127,7 +128,8 @@
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-[0.9375rem] line-clamp-1 text-wrap"
: ''} text-gray-500 dark:text-gray-500 text-[0.9375rem] text-wrap"
class:line-clamp-1={clamp}
>
<!-- $i18n.t(`Searching "{{searchQuery}}"`) -->
{#if status?.description?.includes('{{searchQuery}}')}

View file

@ -58,6 +58,13 @@
$: terminal = resolveTerminal();
$: unavailable = !terminal;
$: previewKey = terminal ? `${terminal.url}|${terminal.key}|${path}|${chatId}` : '';
$: isPerChatTerminal =
($terminalServers ?? []).find((server: any) => server.id === selector)?.contexts?.chat
?.context_id === 'chat_id';
$: previewBaseUrl =
terminal && isPerChatTerminal && chatId
? `${terminal.url}/chats/${encodeURIComponent(chatId)}`
: (terminal?.url ?? '');
$: previewClass = isImage(path)
? 'h-[22rem]'
: isPdf(path) ||
@ -299,7 +306,7 @@
{fileSqliteData}
{fileDocxData}
{fileContent}
baseUrl={terminal?.url ?? ''}
baseUrl={previewBaseUrl}
apiKey={terminal?.key ?? ''}
{fileOfficeHtml}
{fileOfficeSlides}

View file

@ -1,4 +1,8 @@
<script lang="ts">
import EditPencilIcon from '$lib/components/layout/Sidebar/icons/EditPencil.svelte';
import CopyIcon from '$lib/components/layout/Sidebar/icons/Copy.svelte';
import TrashIcon from '$lib/components/layout/Sidebar/icons/Trash.svelte';
import ChevronRightIcon from '$lib/components/layout/Sidebar/icons/ChevronRight.svelte';
import { toast } from 'svelte-sonner';
import { tick, getContext, onMount } from 'svelte';
@ -41,6 +45,7 @@
export let isFirstMessage: boolean;
export let readOnly: boolean;
export let shareMode: 'continue' | null = null;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
@ -330,7 +335,7 @@
<div class=" mt-2 -mx-1 flex justify-between text-sm font-normal">
<div>
{#if isOwn}
{#if isOwn && !readOnly}
<button
id="save-edit-message-button"
class="flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-100 px-2.5 text-xs font-normal text-gray-800 transition hover:bg-gray-100/70 disabled:opacity-60 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-850/60"
@ -442,11 +447,13 @@
{#if edit !== true && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
<div
class=" flex {($settings?.chatBubble ?? true)
class="flex items-center {($settings?.chatBubble ?? true)
? isOwn
? 'justify-end'
: 'justify-start'
: 'items-center'} text-gray-600 dark:text-gray-500"
: 'items-center'} {($settings?.highContrastMode ?? false)
? 'text-gray-600 dark:text-gray-500'
: 'text-gray-600/80 dark:text-gray-500/70'}"
>
{#if message.timestamp}
<Tooltip
@ -473,30 +480,17 @@
<div class="flex self-center" dir="ltr">
<button
aria-label={$i18n.t('Previous message')}
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
class="self-center inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-gray-400 hover:text-gray-600 rounded-md transition"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
<ChevronRightIcon className="size-4 rotate-180" />
</button>
{#if messageIndexEdit}
<div
class="text-sm flex justify-center font-normal self-center dark:text-gray-100 min-w-fit"
class="text-sm flex justify-center font-normal self-center dark:text-gray-500 min-w-fit"
>
<input
id="message-index-input-{message.id}"
@ -517,13 +511,13 @@
messageIndexEdit = false;
}
}}
class="bg-transparent font-normal self-center dark:text-gray-100 min-w-fit outline-hidden"
class="bg-transparent font-normal self-center dark:text-gray-500 min-w-fit outline-hidden"
/>/{siblings.length}
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-sm tracking-widest font-normal self-center dark:text-gray-100 min-w-fit"
class="text-sm tracking-widest font-normal self-center dark:text-gray-500 min-w-fit"
on:dblclick={async () => {
messageIndexEdit = true;
@ -541,54 +535,28 @@
<button
aria-label={$i18n.t('Next message')}
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
class="self-center inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-gray-400 hover:text-gray-600 rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
<ChevronRightIcon className="size-4" />
</button>
</div>
{/if}
{/if}
{#if !compactPreview && !readOnly}
{#if !compactPreview && (!readOnly || (shareMode === 'continue' && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.edit ?? true))))}
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<button
class="{($settings?.highContrastMode ?? false)
? ''
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition edit-user-message-button"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition edit-user-message-button"
aria-label={$i18n.t('Edit')}
on:click={() => {
editMessageHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
<EditPencilIcon />
</button>
</Tooltip>
{/if}
@ -598,26 +566,13 @@
<button
class="{($settings?.highContrastMode ?? false)
? ''
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
aria-label={$i18n.t('Copy')}
on:click={() => {
copyToClipboard(message.content);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
<CopyIcon />
</button>
</Tooltip>
{/if}
@ -629,7 +584,7 @@
aria-label={$i18n.t('Delete')}
class="{($settings?.highContrastMode ?? false)
? ''
: 'hover-reveal'} p-1 rounded-sm dark:hover:text-white hover:text-black transition"
: 'hover-reveal'} inline-flex size-7 shrink-0 items-center justify-center p-1.5 rounded-lg dark:hover:text-gray-400 hover:text-gray-600 transition"
on:click={(e) => {
if (e.shiftKey) {
deleteMessageHandler();
@ -638,20 +593,7 @@
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<TrashIcon />
</button>
</Tooltip>
{/if}
@ -662,30 +604,17 @@
<div class="flex self-center" dir="ltr">
<button
aria-label={$i18n.t('Previous message')}
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
class="self-center inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-gray-400 hover:text-gray-600 rounded-md transition"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
<ChevronRightIcon className="size-4 rotate-180" />
</button>
{#if messageIndexEdit}
<div
class="text-sm flex justify-center font-normal self-center dark:text-gray-100 min-w-fit"
class="text-sm flex justify-center font-normal self-center dark:text-gray-500 min-w-fit"
>
<input
id="message-index-input-{message.id}"
@ -706,13 +635,13 @@
messageIndexEdit = false;
}
}}
class="bg-transparent font-normal self-center dark:text-gray-100 min-w-fit outline-hidden"
class="bg-transparent font-normal self-center dark:text-gray-500 min-w-fit outline-hidden"
/>/{siblings.length}
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-sm tracking-widest font-normal self-center dark:text-gray-100 min-w-fit"
class="text-sm tracking-widest font-normal self-center dark:text-gray-500 min-w-fit"
on:dblclick={async () => {
messageIndexEdit = true;
@ -730,25 +659,12 @@
<button
aria-label={$i18n.t('Next message')}
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
class="self-center inline-flex size-7 shrink-0 items-center justify-center p-1.5 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-gray-400 hover:text-gray-600 rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
<ChevronRightIcon className="size-4" />
</button>
</div>
{/if}

View file

@ -36,11 +36,13 @@
import ChatCheck from '../icons/ChatCheck.svelte';
import Knobs from '../icons/Knobs.svelte';
import { isTemporaryChatId } from '$lib/utils/chatId';
import ChatParticipants from '$lib/components/common/ChatParticipants.svelte';
const i18n: any = getContext('i18n');
export let initNewChat: Function;
export let readOnly: boolean = false;
export let liveParticipants: { id: string; name: string }[] = [];
export let shareEnabled: boolean = false;
export let scrollTop = 0;
export let scrollToTop: (() => void) | null = null;
@ -113,7 +115,9 @@
{/if}
<div
class="flex-1 overflow-hidden max-w-full mt-0.5 py-0.5 pl-1 {$showSidebar ? 'ml-1' : ''}"
class="flex-1 min-w-0 overflow-hidden max-w-full mt-0.5 py-0.5 pl-1 {$showSidebar
? 'ml-1'
: ''}"
>
{#if chat?.id}
<div class="flex max-w-full min-w-0 items-center gap-2 mr-2">
@ -174,7 +178,7 @@
{/if}
</div>
<div class="lg:mr-1 flex flex-none items-center gap-2 self-center">
<div class="lg:mr-1 flex flex-none items-center gap-3 self-center">
<!-- <div class="md:hidden flex self-center w-[0.0625rem] h-5 mx-2 bg-gray-300 dark:bg-stone-700" /> -->
{#if $user?.role === 'user' ? ($user?.permissions?.chat?.temporary ?? true) && !($user?.permissions?.chat?.temporary_enforced ?? false) : true}
@ -243,6 +247,14 @@
</Tooltip>
{/if}
{#if liveParticipants.length > 0}
<ChatParticipants
participants={liveParticipants}
mode="presence"
avatarClass="size-5"
/>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)}
<Tooltip content={$i18n.t('Controls')}>
<button

View file

@ -1,4 +1,5 @@
<script lang="ts">
import type { TerminalContext } from '$lib/types';
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@ -50,6 +51,7 @@
export let prompt = '';
export let files = [];
export let terminalContext: TerminalContext | null = null;
export let messageInput = null;
export let selectedToolIds = [];
@ -243,6 +245,7 @@
<div class="text-base font-normal @md:max-w-3xl w-full py-3 {atSelectedModel ? 'mt-2' : ''}">
{#if !($selectedFolder && folderReadOnly)}
<MessageInput
{terminalContext}
{callActive}
{callEnabled}
bind:this={messageInput}

View file

@ -10,6 +10,7 @@
const i18n: Writable<ChatListI18n> = getContext('i18n');
import dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import { getTimeRange } from '$lib/utils';
import type { ChatParticipant } from '$lib/apis/folders';
@ -21,6 +22,7 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import { chatId, socket } from '$lib/stores';
dayjs.extend(calendar);
dayjs.extend(localizedFormat);
type ChatListItem = {

View file

@ -9,7 +9,7 @@
import { toast } from 'svelte-sonner';
import { selectedFolder, user } from '$lib/stores';
import { selectedFolder } from '$lib/stores';
import {
deleteFolderById,
@ -20,7 +20,6 @@
import { getChatsByFolderId } from '$lib/apis/chats';
import FolderModal from '$lib/components/layout/Sidebar/Folders/FolderModal.svelte';
import FolderShareModal from '$lib/components/layout/Sidebar/Folders/FolderShareModal.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -38,7 +37,6 @@
let showFolderModal = false;
let showCreateSubFolderModal = false;
let showShareModal = false;
let showDeleteConfirm = false;
let deleteFolderContents = true;
@ -177,8 +175,6 @@
onSubmit={createSubFolderHandler}
/>
<FolderShareModal bind:show={showShareModal} {folder} />
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete folder?')}
@ -247,14 +243,10 @@
{#if !readOnly}
<div class="flex items-center translate-x-2.5">
<FolderMenu
canShare={$user?.role === 'admin' || folder.user_id === $user?.id}
align="end"
onEdit={() => {
showFolderModal = true;
}}
onShare={() => {
showShareModal = true;
}}
onDelete={() => {
showDeleteConfirm = true;
}}

View file

@ -118,7 +118,9 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.streamChatResponse.label')}
</div>
<button
@ -153,7 +155,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.streamDeltaChunkSize.label')}
</div>
<button
@ -195,7 +199,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.contextCompactionThreshold.label')}
</div>
<button
@ -243,7 +249,9 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.functionCalling.label')}
</div>
<button
@ -278,7 +286,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.reasoningTags.label')}
</div>
<button
@ -343,7 +353,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.seed.label')}
</div>
@ -387,7 +399,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.stopSequence.label')}
</div>
@ -430,7 +444,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.temperature.label')}
</div>
<button
@ -467,7 +483,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.reasoningEffort.label')}
</div>
<button
@ -511,7 +529,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.logitBias.label')}
</div>
<button
@ -557,7 +577,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.maxTokens.label')}
</div>
@ -599,7 +621,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.topK.label')}
</div>
<button
@ -657,7 +681,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.topP.label')}
</div>
@ -697,7 +723,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.minP.label')}
</div>
<button
@ -736,7 +764,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.frequencyPenalty.label')}
</div>
@ -776,7 +806,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.presencePenalty.label')}
</div>
@ -814,7 +846,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.mirostat.label')}
</div>
<button
@ -854,7 +888,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.mirostatEta.label')}
</div>
<button
@ -893,7 +929,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.mirostatTau.label')}
</div>
@ -931,7 +969,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.repeatLastN.label')}
</div>
@ -972,7 +1012,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.tfsZ.label')}
</div>
@ -1012,7 +1054,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.repeatPenalty.label')}
</div>
@ -1053,7 +1097,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.useMmap.label')}
</div>
<button
@ -1096,7 +1142,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.useMlock.label')}
</div>
@ -1140,7 +1188,9 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.think.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})
@ -1196,7 +1246,9 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.format.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})
@ -1238,7 +1290,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.numKeep.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})
@ -1280,7 +1334,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.numCtx.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})
@ -1324,7 +1380,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.numBatch.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})
@ -1369,7 +1427,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.numThread.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})
@ -1412,7 +1472,9 @@
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.numGpu.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})
@ -1455,7 +1517,9 @@
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class="self-center text-xs font-normal text-gray-600 dark:text-gray-400">
<div
class="self-center text-xs font-normal text-gray-600 dark:text-gray-400 cursor-help underline decoration-dotted decoration-gray-400/70 underline-offset-4 dark:decoration-gray-500"
>
{$i18n.t('settings.personal.general.parameters.keepAlive.label')} ({$i18n.t(
'settings.personal.general.parameters.ollama.label'
)})

View file

@ -76,7 +76,8 @@
};
const getConflict = (id: Shortcut, chord: string): Shortcut | null => {
if (!isConfigurableShortcut(id) || !chord) return null;
// Modals always close on Esc on their own, so sharing it is not a conflict
if (!isConfigurableShortcut(id) || !chord || chord === 'Escape') return null;
for (const [otherId, otherChord] of Object.entries($keybindings)) {
if (otherId !== id && otherChord === chord) return otherId as Shortcut;
}

View file

@ -12,10 +12,15 @@
export let avatarClass = 'size-4';
export let activeProfileId: string | null = null;
export let overflowOpen = false;
export let mode: 'contributors' | 'presence' = 'contributors';
// Preserve the base overflow label alongside its generated plural forms.
// $i18n.t('Show all {{count}} contributors');
$: people = participants.length ? participants : owner?.id ? [owner] : [];
$: people = participants.length
? participants
: mode === 'contributors' && owner?.id
? [owner]
: [];
$: if (activeProfileId && !people.some((person) => person.id === activeProfileId)) {
activeProfileId = null;
}
@ -43,10 +48,16 @@
{/snippet}
<div class="flex shrink-0 items-center gap-1" data-chat-participants draggable="false">
<div class="flex items-center -space-x-1 rtl:space-x-reverse">
<div
class="flex items-center {mode === 'presence'
? '-space-x-2'
: '-space-x-1'} rtl:space-x-reverse"
>
{#each people.slice(0, 3) as person (person.id)}
<div class="relative rounded-full hover:z-10 focus-within:z-10">
<ProfilePreview
side={mode === 'presence' ? 'bottom' : 'right'}
align={mode === 'presence' ? 'end' : 'center'}
user={{ id: person.id, name: person.name || $i18n.t('Unknown User') }}
bind:openPreview={
() => !overflowOpen && activeProfileId === person.id,
@ -55,7 +66,11 @@
>
{@render avatar(
person,
`${avatarClass} bg-white ring-1 ring-white dark:bg-gray-900 dark:ring-gray-900`
`${avatarClass} bg-white dark:bg-gray-900 ${
mode === 'presence'
? `ring-2 ${['ring-red-400', 'ring-sky-400', 'ring-lime-500', 'ring-amber-400', 'ring-violet-400'][person.id.charCodeAt(0) % 5]}`
: 'ring-1 ring-white dark:ring-gray-900'
}`
)}
</ProfilePreview>
</div>
@ -65,8 +80,12 @@
{#if people.length > 3}
<Popover.Root bind:open={overflowOpen} onOpenChange={() => (activeProfileId = null)}>
<Popover.Trigger
class="shrink-0 rounded px-0.5 text-[0.625rem] text-gray-500 hover:bg-gray-100 focus-visible:outline dark:text-gray-400 dark:hover:bg-gray-800"
aria-label={$i18n.t('Show all {{count}} contributors', { count: people.length })}
class="shrink-0 rounded px-0.5 {mode === 'presence'
? 'h-6 text-xs'
: 'text-[0.625rem]'} text-gray-500 hover:bg-gray-100 focus-visible:outline dark:text-gray-400 dark:hover:bg-gray-800"
aria-label={mode === 'presence'
? $i18n.t('Collaborators')
: $i18n.t('Show all {{count}} contributors', { count: people.length })}
>
+{people.length - 3}
</Popover.Trigger>
@ -75,15 +94,24 @@
role="dialog"
trapFocus={false}
side="bottom"
align="start"
align={mode === 'presence' ? 'end' : 'start'}
sideOffset={6}
class="z-[9999] w-60 max-w-[calc(100vw-1.5rem)] max-h-80 overflow-y-auto rounded-xl bg-white p-2 text-gray-900 shadow-lg ring-1 ring-black/5 dark:bg-gray-850 dark:text-gray-100 dark:ring-white/10"
aria-label={$i18n.t('Contributors')}
aria-label={mode === 'presence' ? $i18n.t('Collaborators') : $i18n.t('Contributors')}
>
{#if mode === 'presence'}
<div class="px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('Collaborators')}
</div>
{/if}
<ul class="space-y-1">
{#each people as person (person.id)}
<li class="rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800">
<li
class="rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800 [&>button]:max-w-full"
>
<ProfilePreview
side={mode === 'presence' ? 'bottom' : 'right'}
align={mode === 'presence' ? 'end' : 'center'}
user={{ id: person.id, name: person.name || $i18n.t('Unknown User') }}
bind:openPreview={
() => overflowOpen && activeProfileId === person.id,

View file

@ -5,11 +5,19 @@
import { basicSetup, EditorView } from 'codemirror';
import { Compartment, EditorState, type Text } from '@codemirror/state';
import { keymap, type ViewUpdate } from '@codemirror/view';
import { indentWithTab, isolateHistory } from '@codemirror/commands';
import { indentWithTab, isolateHistory, undo } from '@codemirror/commands';
import { DiffUndoHistory } from './diffEditorHistory';
import { LanguageDescription, indentUnit } from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { oneDark } from '@codemirror/theme-one-dark';
import type { MergeView } from '@codemirror/merge';
import type { Chunk, MergeView } from '@codemirror/merge';
import Dropdown from './Dropdown.svelte';
import DropdownMenu from './DropdownMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Check from '$lib/components/icons/Check.svelte';
import Italic from '$lib/components/icons/Italic.svelte';
import LineSpaceSmaller from '$lib/components/icons/LineSpaceSmaller.svelte';
import Modal from './Modal.svelte';
import Tooltip from './Tooltip.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
@ -22,6 +30,12 @@
pairedLinePosition
} from './diffEditorAlignment';
import {
diffPresentation,
setDiffPresentation,
visibleDiffChunks,
unchangedRanges,
presentationDecorations,
type CollapsedPair,
copyChunk,
keepBothChunk,
selectedChunk,
@ -65,8 +79,16 @@
let proposal = '';
let comparing = false;
let merge: MergeView | null = null;
let visibleChunks: readonly Chunk[] = [];
let showOptions = false;
let optionsDropdown: Dropdown;
let ignoreWhitespace = false;
let ignoreEmphasis = false;
let onlyChanges = false;
let updatePresentation: ((reset?: boolean) => void) | null = null;
let activeChunk = 0;
let canKeepBoth = false;
let undoSide: 'a' | 'b' | null = null;
let overview: ReturnType<typeof chunkOverview> = [];
let chunkCount = 0;
let lineNumberDigits = 1;
@ -104,6 +126,7 @@
comparing = proposedContent !== undefined;
activeChunk = chunkCount = 0;
canKeepBoth = false;
undoSide = null;
overview = [];
lineChanges = { removed: 0, added: 0 };
copied = null;
@ -113,6 +136,7 @@
? $i18n.t('Both files are required for comparison.')
: '';
confirmDiscard = false;
showOptions = false;
};
$: if (show && !session) begin();
$: if (!show && session) {
@ -192,18 +216,26 @@
working = merge.a.state.doc.toString();
proposal = merge.b.state.doc.toString();
}
chunkCount = merge.chunks.length;
visibleChunks = visibleDiffChunks(
merge.a.state.doc,
merge.b.state.doc,
merge.chunks,
ignoreWhitespace,
ignoreEmphasis
);
chunkCount = visibleChunks.length;
lineNumberDigits = String(Math.max(merge.a.state.doc.lines, merge.b.state.doc.lines)).length;
lineChanges = changedLineCounts(merge.a.state.doc, merge.b.state.doc, merge.chunks);
overview = chunkOverview(merge.a.state.doc, merge.b.state.doc, merge.chunks);
lineChanges = changedLineCounts(merge.a.state.doc, merge.b.state.doc, visibleChunks);
overview = chunkOverview(merge.a.state.doc, merge.b.state.doc, visibleChunks);
approximate = merge.chunks.some((chunk) => !chunk.precise);
updatePresentation?.();
}
if (update?.selectionSet) {
const index = selectedChunk(merge.chunks, side, update.state.selection.main.head);
const index = selectedChunk(visibleChunks, side, update.state.selection.main.head);
if (index >= 0) activeChunk = index;
}
activeChunk = Math.max(0, Math.min(activeChunk, chunkCount - 1));
const chunk = merge.chunks[activeChunk];
const chunk = visibleChunks[activeChunk];
canKeepBoth =
session?.mode === 'edit' &&
!!chunk &&
@ -212,7 +244,7 @@
const reveal = (index: number) => {
if (!merge || !chunkCount) return;
activeChunk = Math.max(0, Math.min(index, chunkCount - 1));
const chunk = merge.chunks[activeChunk];
const chunk = visibleChunks[activeChunk];
for (const [view, start] of [
[merge.a, chunk.fromA],
[merge.b, chunk.fromB]
@ -220,7 +252,7 @@
const position = Math.min(start, view.state.doc.length);
view.dispatch({
selection: { anchor: position },
effects: EditorView.scrollIntoView(position, { y: 'center' })
effects: EditorView.scrollIntoView(position, { y: 'start', yMargin: 0 })
});
}
refresh();
@ -247,13 +279,13 @@
};
const copy = (direction: MergeDirection) => {
if (session?.mode === 'review' || !merge || !merge.chunks[activeChunk]) return;
if (session?.mode === 'review' || !merge || !visibleChunks[activeChunk]) return;
const destination = direction === 'to-working' ? merge.a : merge.b;
destination.dispatch({
changes: copyChunk(
merge.a.state.doc,
merge.b.state.doc,
merge.chunks[activeChunk],
visibleChunks[activeChunk],
direction
),
annotations: isolateHistory.of('full'),
@ -262,9 +294,16 @@
refresh();
};
const undoLastChange = () => {
if (session?.mode !== 'edit' || loading || !merge || !undoSide) return;
const view = merge[undoSide];
undo(view);
view.focus();
};
const keepBoth = () => {
if (session?.mode !== 'edit' || loading || !merge) return;
const chunk = merge.chunks[activeChunk];
const chunk = visibleChunks[activeChunk];
if (!chunk) return;
const changes = keepBothChunk(merge.a.state.doc, merge.b.state.doc, chunk);
if (!changes) return;
@ -277,23 +316,76 @@
merge.b.focus();
};
const toggleDisplay = (option: 'whitespace' | 'emphasis' | 'unchanged') => {
if (option === 'whitespace') ignoreWhitespace = !ignoreWhitespace;
else if (option === 'emphasis') ignoreEmphasis = !ignoreEmphasis;
else onlyChanges = !onlyChanges;
activeChunk = 0;
if (merge) {
clearLineAlignment(merge.a, merge.b);
merge.reconfigure({ gutter: !ignoreWhitespace && !ignoreEmphasis });
}
updatePresentation?.(true);
refresh();
};
const mountMerge = (node: HTMLElement) => {
let disposed = false;
let view: MergeView | null = null;
let observer: MutationObserver | undefined;
let alignmentFrame = 0;
let presentationFrame = 0;
let expanded: CollapsedPair[] = [];
const schedulePresentation = (reset = false) => {
if (reset) expanded = [];
if (presentationFrame) return;
presentationFrame = requestAnimationFrame(() => {
presentationFrame = 0;
if (disposed || !view) return;
const folds = onlyChanges
? unchangedRanges(view.a.state.doc, view.b.state.doc, visibleChunks).filter(
(pair) =>
!expanded.some((open) => open.a.from <= pair.a.to && open.a.to >= pair.a.from)
)
: [];
for (const side of ['a', 'b'] as const) {
view[side].dispatch({
effects: setDiffPresentation.of(
presentationDecorations(
view[side].state.doc,
side,
visibleChunks,
ignoreWhitespace,
ignoreEmphasis,
folds,
(lines) => $i18n.t('Show {{count}} unchanged lines', { count: lines }),
(pair) => {
expanded.push(pair);
schedulePresentation();
}
)
)
});
}
});
};
updatePresentation = schedulePresentation;
let alignmentDocuments: readonly [Text, Text] | null = null;
let alignmentGeometry = '';
const themes = [new Compartment(), new Compartment()];
const language = [new Compartment(), new Compartment()];
const paths = ['a', 'b'].map((side) => paneFile(side)?.path ?? session?.path);
const review = session?.mode === 'review';
const undoHistory = new DiffUndoHistory();
undoSide = null;
loading = true;
const extensions = (side: 'a' | 'b', index: number) => [
basicSetup,
undoHistory.extension(side),
EditorState.readOnly.of(review),
EditorView.editable.of(!review),
lineAlignment,
diffPresentation,
keymap.of([indentWithTab, { key: 'Mod-s', run: () => true }]),
indentUnit.of(' '),
EditorView.lineWrapping,
@ -304,6 +396,16 @@
themes[index].of(document.documentElement.classList.contains('dark') ? oneDark : []),
language[index].of([]),
EditorView.updateListener.of((update) => {
for (const transaction of update.transactions) {
if (transaction.docChanged) {
for (const pair of expanded) {
pair[side].from = transaction.changes.mapPos(pair[side].from);
pair[side].to = transaction.changes.mapPos(pair[side].to, 1);
}
}
}
for (const transaction of update.transactions) undoHistory.record(side, transaction);
undoSide = undoHistory.nextSide;
if (update.docChanged || update.selectionSet) refresh(update, side);
if (
(update.geometryChanged ||
@ -330,6 +432,8 @@
const resized = alignmentDocuments && geometry !== alignmentGeometry;
alignmentDocuments = documents;
alignmentGeometry = geometry;
const previousSpacersA = view.a.state.field(lineAlignment);
const previousSpacersB = view.b.state.field(lineAlignment);
if (!reset || !clearLineAlignment(view.a, view.b))
alignChangedLines(view.a, view.b, view.chunks);
if (resized) {
@ -353,7 +457,13 @@
effects: EditorView.scrollIntoView(other, { y: 'start', yMargin: 0 })
});
}
view.reconfigure({});
// Remeasuring unchanged spacers feeds CodeMirror's own layout updates back into itself.
if (
resized ||
previousSpacersA !== view.a.state.field(lineAlignment) ||
previousSpacersB !== view.b.state.field(lineAlignment)
)
view.reconfigure({});
}
});
}
@ -372,6 +482,7 @@
a: { doc: working, extensions: extensions('a', 0) },
b: { doc: proposal, extensions: extensions('b', 1) },
parent: node,
gutter: !ignoreWhitespace && !ignoreEmphasis,
diffConfig: diffEditorConfig
});
merge = view;
@ -408,6 +519,8 @@
destroy: () => {
disposed = true;
cancelAnimationFrame(alignmentFrame);
cancelAnimationFrame(presentationFrame);
if (updatePresentation === schedulePresentation) updatePresentation = null;
observer?.disconnect();
view?.destroy();
if (merge === view) merge = null;
@ -480,10 +593,8 @@
<button
type="button"
class="secondary"
disabled={!chunkCount}
aria-label={$i18n.t('Copy change to original')}
title={$i18n.t('Copy change to original')}
on:click={() => copy('to-working')}>← {$i18n.t('Original')}</button
disabled={loading || !undoSide}
on:click={undoLastChange}>{$i18n.t('Undo')}</button
>
<button
type="button"
@ -491,7 +602,15 @@
disabled={!chunkCount}
aria-label={$i18n.t('Copy change to proposal')}
title={$i18n.t('Copy change to proposal')}
on:click={() => copy('to-proposal')}>{$i18n.t('Proposed revision')} →</button
on:click={() => copy('to-proposal')}>{$i18n.t('Original')} →</button
>
<button
type="button"
class="secondary"
disabled={!chunkCount}
aria-label={$i18n.t('Copy change to original')}
title={$i18n.t('Copy change to original')}
on:click={() => copy('to-working')}>← {$i18n.t('Proposed revision')}</button
>
<button
type="button"
@ -520,6 +639,77 @@
{/if}
</div>
{/if}
{#if comparing}
<Dropdown bind:this={optionsDropdown} bind:show={showOptions} align="end">
<button
type="button"
class="comparison-options shrink-0 text-gray-500 hover:text-gray-900 dark:hover:text-white"
disabled={loading}
aria-label={$i18n.t('Comparison options')}
aria-haspopup="menu"
aria-expanded={showOptions}
>
<EllipsisHorizontal className="size-4" />
</button>
<div
slot="content"
role="presentation"
on:keydown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
optionsDropdown.close();
}
}}
>
<DropdownMenu className="min-w-48">
<button
type="button"
role="menuitemcheckbox"
aria-checked={ignoreWhitespace}
on:click={() => toggleDisplay('whitespace')}
>
<svg
class="size-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
><path d="M4 9v6h16V9" stroke-linecap="round" stroke-linejoin="round" /></svg
>
<span class="flex-1 text-left">{$i18n.t('Ignore whitespace')}</span>
<span class="size-3.5 shrink-0"
>{#if ignoreWhitespace}<Check className="size-3.5" />{/if}</span
>
</button>
<button
type="button"
role="menuitemcheckbox"
aria-checked={ignoreEmphasis}
on:click={() => toggleDisplay('emphasis')}
>
<Italic className="size-3.5 shrink-0" />
<span class="flex-1 text-left">{$i18n.t('Ignore emphasis markers')}</span>
<span class="size-3.5 shrink-0"
>{#if ignoreEmphasis}<Check className="size-3.5" />{/if}</span
>
</button>
<button
type="button"
role="menuitemcheckbox"
aria-checked={onlyChanges}
on:click={() => toggleDisplay('unchanged')}
>
<LineSpaceSmaller className="size-3.5 shrink-0" />
<span class="flex-1 text-left">{$i18n.t('Only show changes')}</span>
<span class="size-3.5 shrink-0"
>{#if onlyChanges}<Check className="size-3.5" />{/if}</span
>
</button>
</DropdownMenu>
</div>
</Dropdown>
{/if}
<button
type="button"
class="comparison-close shrink-0 text-gray-500 hover:text-gray-900 dark:hover:text-white"
@ -596,6 +786,7 @@
{#key editorRevision}
<div
class="merge-container min-h-0 flex-1"
class:filtering-differences={ignoreWhitespace || ignoreEmphasis}
style:--diff-line-digits={lineNumberDigits}
use:mountMerge
></div>
@ -733,6 +924,7 @@
order: 3;
width: 100%;
}
.comparison-options,
.comparison-close {
order: 2;
}
@ -801,39 +993,49 @@
.merge-container :global(.cm-mergeViewEditor) {
min-width: 0;
}
.merge-container :global(.cm-merge-a .cm-changedLine) {
.merge-container :global(.cm-merge-a .cm-changedLine),
.merge-container :global(.cm-merge-a .cm-visibleChangedLine) {
background: #fce9e6;
}
.merge-container :global(.cm-merge-b .cm-changedLine) {
.merge-container :global(.cm-merge-b .cm-changedLine),
.merge-container :global(.cm-merge-b .cm-visibleChangedLine) {
background: #e0f5f1;
}
.merge-container :global(.cm-merge-a .cm-changedText) {
.merge-container :global(.cm-merge-a .cm-changedText),
.merge-container :global(.cm-merge-a .cm-visibleChangedText) {
background: #f9b9ad;
}
.merge-container :global(.cm-merge-b .cm-changedText) {
.merge-container :global(.cm-merge-b .cm-changedText),
.merge-container :global(.cm-merge-b .cm-visibleChangedText) {
background: #a3e3d8;
}
.merge-container :global(.cm-changedText) {
.merge-container :global(.cm-changedText),
.merge-container :global(.cm-visibleChangedText) {
border-radius: 3px;
}
.merge-container :global(.cm-mergeSpacer) {
background: repeating-linear-gradient(135deg, transparent 0 3px, #94a3b814 3px 4px);
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedLine) {
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedLine),
:global(.dark) .merge-container :global(.cm-merge-a .cm-visibleChangedLine) {
background: #3d1915;
color: #f4d0c7;
}
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedLine) {
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedLine),
:global(.dark) .merge-container :global(.cm-merge-b .cm-visibleChangedLine) {
background: #0d302a;
color: #a9e9dd;
}
:global(.dark) .merge-container :global(.cm-changedLine span) {
:global(.dark) .merge-container :global(.cm-changedLine span),
:global(.dark) .merge-container :global(.cm-visibleChangedLine span) {
color: inherit;
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedText) {
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedText),
:global(.dark) .merge-container :global(.cm-merge-a .cm-visibleChangedText) {
background: #77362c;
}
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedText) {
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedText),
:global(.dark) .merge-container :global(.cm-merge-b .cm-visibleChangedText) {
background: #176456;
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedLineGutter) {
@ -859,4 +1061,12 @@
/* Reserve the same gutter width, including padding and fractional glyph rounding. */
min-width: calc(var(--diff-line-digits) * 1ch + 9px);
}
.filtering-differences :global(.cm-changedLine:not(.cm-visibleChangedLine)) {
background: transparent !important;
color: inherit !important;
}
.filtering-differences :global(.cm-changedText:not(.cm-visibleChangedText)) {
background: transparent !important;
}
</style>

View file

@ -5,6 +5,9 @@
import { clampDocumentTargetPage } from '$lib/utils/documentPreview';
import Spinner from './Spinner.svelte';
import { wordAnchors } from '$lib/utils/comments/wordAnchors';
import { highlightRange, commentPin } from '$lib/utils/comments/highlights';
import type { FileComment } from '$lib/utils/comments/types';
type I18n = {
t: (key: string, values?: Record<string, unknown>) => string;
@ -15,6 +18,69 @@
export let data: ArrayBuffer | null = null;
export let className = '';
export let targetPage: number | null = null;
export let reviewComments: FileComment[] | null = null;
export let selectedComment: FileComment | null = null;
export let onCommentSelect: ((id: string) => void) | null = null;
export let onLocationResult: ((found: boolean) => void) | null = null;
let anchors = new Map<string, Range>();
let lastSelected: FileComment | null = null;
let renderedData: ArrayBuffer | null = null;
export let onAnchors: ((ids: string[]) => void) | null = null;
const updateCommentMarkers = () => {
if (!containerEl || !reviewComments) return;
containerEl.querySelector('.word-comment-layer')?.remove();
const layer = document.createElement('div');
layer.className = 'word-comment-layer';
Object.assign(layer.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '2'
});
const base = containerEl.getBoundingClientRect();
for (const [id, range] of anchors) {
const rect =
range.getClientRects()[0] ?? range.startContainer.parentElement?.getBoundingClientRect();
if (!rect) continue;
if (highlightRange(containerEl, layer, range, `word:${id}`, `${$i18n.t('Comment')} ${id}`))
continue;
const button = commentPin(`word:${id}`, `${$i18n.t('Comment')} ${id}`);
button.dataset.wordComment = id;
button.style.top = `${rect.top - base.top}px`;
button.style.left = `${Math.max(0, rect.left - base.left - 24)}px`;
button.onclick = () => onCommentSelect?.(id);
layer.appendChild(button);
}
const location = selectedComment?.location;
if (location?.kind === 'word') {
const range = anchors.get(location.anchor);
onLocationResult?.(!!range);
if (range) {
const rects = Array.from(range.getClientRects());
if (!rects.length && range.startContainer.parentElement)
rects.push(range.startContainer.parentElement.getBoundingClientRect());
for (const rect of rects) {
const highlight = document.createElement('div');
highlight.dataset.wordSelection = location.anchor;
Object.assign(highlight.style, {
position: 'absolute',
top: `${rect.top - base.top}px`,
left: `${rect.left - base.left}px`,
width: `${Math.max(2, rect.width)}px`,
height: `${rect.height}px`,
background: 'rgba(250,204,21,.3)',
outline: '1px solid #2563eb'
});
layer.appendChild(highlight);
}
}
}
containerEl.appendChild(layer);
if (selectedComment !== lastSelected) {
lastSelected = selectedComment;
layer.querySelector('[data-word-selection]')?.scrollIntoView({ block: 'center' });
}
};
let outerContainer: HTMLDivElement;
let containerEl: HTMLDivElement;
@ -34,6 +100,7 @@
const clearPreview = () => {
if (containerEl) containerEl.innerHTML = '';
anchors.clear();
if (styleEl) styleEl.innerHTML = '';
fallbackHtml = '';
error = false;
@ -117,6 +184,7 @@
};
const renderDocx = async (arrayBuffer: ArrayBuffer | null) => {
renderedData = arrayBuffer;
const currentRender = ++renderId;
clearPreview();
zoomLevel = 1;
@ -128,10 +196,13 @@
try {
const { renderAsync } = await import('docx-preview');
const body = document.createElement('div');
const styles = document.createElement('div');
if (currentRender !== renderId) return;
await renderAsync(arrayBuffer.slice(0), containerEl, styleEl, {
await renderAsync(arrayBuffer.slice(0), body, styles, {
renderComments: reviewComments !== null,
breakPages: true,
className: 'docx',
ignoreLastRenderedPageBreak: false,
@ -144,12 +215,28 @@
renderHeaders: true,
useBase64URL: true
});
if (currentRender !== renderId) return;
containerEl.replaceChildren(...Array.from(body.childNodes));
styleEl.replaceChildren(...Array.from(styles.childNodes));
// The shared panel replaces the renderer's hover popovers and global highlight.
if (reviewComments) {
containerEl
.querySelectorAll('.docx-comment-ref,.docx-comment-popover')
.forEach((el) => el.remove());
anchors = wordAnchors(containerEl);
onAnchors?.([...anchors.keys()].map((id) => `word:${id}`));
(globalThis.CSS as typeof CSS & { highlights?: Map<string, unknown> }).highlights?.delete(
'docx-comments'
);
}
stripUnsafeLinkTargets();
await tick();
updateFitScale();
await scrollToTargetPage();
updateCommentMarkers();
} catch (e) {
console.error('Error rendering DOCX preview:', e);
onLocationResult?.(false);
try {
const { docxToHtml } = await import('$lib/utils/docxToHtml');
@ -164,7 +251,7 @@
}
};
$: if (mounted) renderDocx(data);
$: if (mounted && data !== renderedData) void renderDocx(data);
$: if (!loading && targetPage && !fallbackHtml) {
void scrollToTargetPage();
@ -174,12 +261,18 @@
mounted = true;
void tick().then(() => {
if (!outerContainer) return;
resizeObserver = new ResizeObserver(updateFitScale);
resizeObserver = new ResizeObserver(() => {
updateFitScale();
void tick().then(updateCommentMarkers);
});
resizeObserver.observe(outerContainer);
updateFitScale();
});
});
$: if (!loading && selectedComment) void tick().then(updateCommentMarkers);
$: if (docxScale && !loading) void tick().then(updateCommentMarkers);
onDestroy(() => {
renderId += 1;
resizeObserver?.disconnect();
@ -218,7 +311,7 @@
class="h-full overflow-auto overscroll-contain"
on:wheel|nonpassive={handleWheel}
>
<div bind:this={containerEl}></div>
<div bind:this={containerEl} style="position: relative;"></div>
</div>
{/if}

View file

@ -7,6 +7,7 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Search from '$lib/components/icons/Search.svelte';
import emojiGroups from '$lib/emoji-groups.json';
import emojiShortCodes from '$lib/emoji-shortcodes.json';
@ -80,7 +81,7 @@
emojis = emojiShortCodes;
}
}
// Flatten emoji groups and group them into rows of 8 for virtual scrolling
// Flatten emoji groups and group them into rows of 9 for virtual scrolling
$: {
flattenedEmojis = [];
@ -115,13 +116,13 @@
);
}
});
// Group emojis into rows of 8
// Group emojis into rows of 9
emojiRows = [];
let currentRow = [];
flattenedEmojis.forEach((item) => {
if (item.type === 'emoji') {
currentRow.push(item);
if (currentRow.length === 8) {
if (currentRow.length === 9) {
emojiRows.push(currentRow);
currentRow = [];
}
@ -166,12 +167,13 @@
<div slot="content">
<div
class="max-w-full w-72 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 rounded-xl z-9999 shadow-lg dark:text-white"
class="max-w-full w-84 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 rounded-2xl z-9999 shadow-lg dark:text-white"
>
<div class="mb-0.5 px-3 pt-2 pb-1.5">
<div class="flex items-center gap-2 mb-0.5 px-3 pt-2 pb-1.5">
<Search className="size-3.5 shrink-0 text-gray-500" />
<input
type="text"
class="w-full text-[0.8125rem] bg-transparent outline-hidden"
class="min-w-0 flex-1 text-[0.8125rem] bg-transparent outline-hidden"
placeholder={$i18n.t('Search all emojis')}
bind:value={search}
/>
@ -184,17 +186,19 @@
{$i18n.t('No results')}
</div>
{:else}
<div class="w-full flex ml-0.5">
<VirtualList rowHeight={ROW_HEIGHT} items={emojiRows} height={384} let:item>
<div class="w-full h-full">
<VirtualList rowHeight={ROW_HEIGHT} items={emojiRows} height="100%" let:item>
<div class="w-full mb-2.5">
{#if item.length === 1 && item[0].type === 'group'}
<!-- Render group header -->
<div class="text-xs font-normal -mb-1 text-gray-500 dark:text-gray-400">
<div
class="px-0.25 text-[0.6875rem] font-normal -mb-1 text-gray-500 dark:text-gray-400"
>
{item[0].label}
</div>
{:else}
<!-- Render emojis in a row -->
<div class="flex items-center gap-1.5 w-full">
<div class="flex items-center gap-1.5 w-full px-0.5">
{#each item as emojiItem}
<Tooltip
content={emojiItem.shortCodes.map((code) => `:${code}:`).join(', ')}

View file

@ -600,52 +600,3 @@
</div>
</div>
</Modal>
<style>
:global(.excel-table-container table) {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
line-height: 1.25rem;
}
:global(.excel-table-container table td),
:global(.excel-table-container table th) {
border-width: 1px;
border-style: solid;
border-color: var(--color-gray-300, #cdcdcd);
padding: 0.5rem 0.75rem;
text-align: left;
}
:global(.dark .excel-table-container table td),
:global(.dark .excel-table-container table th) {
border-color: var(--color-gray-600, #676767);
}
:global(.excel-table-container table th) {
background-color: var(--color-gray-100, #ececec);
font-weight: 600;
}
:global(.dark .excel-table-container table th) {
background-color: var(--color-gray-800, #333);
color: var(--color-gray-100, #ececec);
}
:global(.excel-table-container table tr:nth-child(even)) {
background-color: var(--color-gray-50, #f9f9f9);
}
:global(.dark .excel-table-container table tr:nth-child(even)) {
background-color: rgba(38, 38, 38, 0.5);
}
:global(.excel-table-container table tr:hover) {
background-color: var(--color-gray-100, #ececec);
}
:global(.dark .excel-table-container table tr:hover) {
background-color: rgba(51, 51, 51, 0.5);
}
</style>

View file

@ -246,8 +246,10 @@
}
saveSettings({
imageCompressionSize: imageCompressionSize,
fontFamily
...(isDefaultSetting('imageCompressionSize') ? {} : { imageCompressionSize }),
...(isDefaultSetting('fontFamily') && fontFamily === defaultFontFamily()
? {}
: { fontFamily })
});
};

View file

@ -4,6 +4,10 @@
import panzoom, { type PanZoom } from 'panzoom';
import { clampDocumentTargetPage } from '$lib/utils/documentPreview';
import Spinner from './Spinner.svelte';
import { readPdfComments } from '$lib/utils/comments/pdf';
import { mapWordComments, type PdfTextRun } from '$lib/utils/comments/pdfAnchors';
import { findTextRange, highlightRange, commentPin } from '$lib/utils/comments/highlights';
import type { FileComment, CommentResult } from '$lib/utils/comments/types';
const i18n: any = getContext('i18n');
@ -14,6 +18,202 @@
export let singlePage = false;
export let itemLabel = 'Page';
export let onPageChange: ((page: number) => void) | null = null;
export let onComments: ((result: CommentResult) => void) | null = null;
export let onCommentsError: (() => void) | null = null;
export let onCommentSelect: ((comment: FileComment) => void) | null = null;
export let selectedComment: FileComment | null = null;
export let sourceComments: FileComment[] | null = null;
export let onAnchors: ((ids: string[]) => void) | null = null;
let mappedSource: FileComment[] | null = null;
let mappedDoc: PdfDocument | null = null;
let textRuns: Promise<PdfTextRun[]> | null = null;
const mapSourceComments = async () => {
if (!sourceComments || !pdfDoc || (mappedSource === sourceComments && mappedDoc === pdfDoc))
return;
const doc = pdfDoc;
const source = sourceComments;
mappedDoc = doc;
mappedSource = source;
if (!textRuns)
textRuns = (async () => {
const runs: PdfTextRun[] = [];
for (let number = 1; number <= doc.numPages; number++) {
const page = await doc.getPage(number);
const content = await page.getTextContent();
for (const item of content.items)
if ('str' in item) {
const [a, b, c, d, x, y] = item.transform;
const height = item.height || Math.hypot(c, d);
runs.push({ text: item.str, page: number, rect: [x, y, x + item.width, y + height] });
}
}
return runs;
})();
try {
const word = source.some((c) => c.location?.kind === 'word');
const mapped = word ? mapWordComments(source, await textRuns) : [];
if (!word)
for (const c of source) {
const loc = c.location;
if (
loc?.kind !== 'slide' ||
loc.hidden ||
!loc.pdfPage ||
loc.pdfPage > doc.numPages ||
loc.x === undefined ||
loc.y === undefined ||
!loc.width ||
!loc.height
)
continue;
const page = await doc.getPage(loc.pdfPage);
const [left, bottom, right, top] = page.view;
const x = left + (loc.x / loc.width) * (right - left);
const y = top - (loc.y / loc.height) * (top - bottom);
mapped.push({
...c,
location: { kind: 'pdf', page: loc.pdfPage, rect: [x, y - 18, x + 18, y] }
});
}
if (mappedDoc !== doc || mappedSource !== source || pdfDoc !== doc) return;
comments = mapped;
onAnchors?.(comments.map((c) => c.id));
void renderCommentMarkers();
} catch {
if (pdfDoc === doc) onAnchors?.([]);
}
};
let comments: FileComment[] = [];
let markerToken = 0;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
let lastHeight = 0;
let resizeTimer: ReturnType<typeof setTimeout>;
const renderCommentMarkers = async () => {
const token = ++markerToken;
if (!sceneElement || !pdfDoc || (!onComments && !sourceComments)) return;
const doc = pdfDoc;
for (const wrapper of Array.from(
sceneElement.querySelectorAll<HTMLElement>('.pdf-page-wrapper')
)) {
wrapper.querySelector('.file-comment-layer')?.remove();
const pageNumber = Number(wrapper.dataset.pageNumber);
const page = await doc.getPage(pageNumber);
if (token !== markerToken) return;
const unit = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: parseFloat(wrapper.style.width) / unit.width });
const layer = document.createElement('div');
layer.className = 'file-comment-layer';
Object.assign(layer.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '4'
});
for (const comment of comments.filter(
(c) => c.location?.kind === 'pdf' && c.location.page === pageNumber && !c.parentId
)) {
const loc = comment.location;
if (loc?.kind !== 'pdf' || loc.rect.length !== 4 || !loc.rect.every(Number.isFinite))
continue;
const rect = viewport.convertToViewportRectangle(loc.rect);
const left = Math.min(rect[0], rect[2]);
const top = Math.min(rect[1], rect[3]);
const source = sourceComments?.find((c) => c.id === comment.id);
if (source?.location?.kind === 'word' && source.location.quote) {
const range = findTextRange(sceneElement, source.location.quote);
if (
range &&
highlightRange(
wrapper,
layer,
range,
comment.id,
`${$i18n.t('Comment')}: ${comment.author || $i18n.t('Unknown author')}`
)
)
continue;
}
const regions = loc.quads?.length
? Array.from({ length: Math.floor(loc.quads.length / 8) }, (_, i) => {
const q = loc.quads!.slice(i * 8, i * 8 + 8);
return [
Math.min(q[0], q[2], q[4], q[6]),
Math.min(q[1], q[3], q[5], q[7]),
Math.max(q[0], q[2], q[4], q[6]),
Math.max(q[1], q[3], q[5], q[7])
];
})
: [];
for (const region of regions) {
const r = viewport.convertToViewportRectangle(region);
const target = document.createElement('button');
target.type = 'button';
target.dataset.fileCommentId = comment.id;
target.dataset.commentHighlight = comment.id;
target.setAttribute(
'aria-label',
`${$i18n.t('Comment')}: ${comment.author || $i18n.t('Unknown author')}`
);
Object.assign(target.style, {
position: 'absolute',
left: `${Math.min(r[0], r[2])}px`,
top: `${Math.min(r[1], r[3])}px`,
width: `${Math.abs(r[2] - r[0])}px`,
height: `${Math.abs(r[3] - r[1])}px`,
pointerEvents: 'none',
background: 'transparent',
border: '0',
borderBottom: '1px solid rgba(180,130,30,.6)',
cursor: 'help'
});
layer.appendChild(target);
}
if (regions.length) continue;
const button = commentPin(
comment.id,
`${$i18n.t('Comment')}: ${comment.author || $i18n.t('Unknown author')}`
);
button.dataset.commentId = comment.id;
button.style.left = `${Math.max(0, Math.min(viewport.width - 20, left - 22))}px`;
button.style.top = `${Math.max(0, Math.min(viewport.height - 20, top))}px`;
button.onclick = () => onCommentSelect?.(comment);
layer.appendChild(button);
}
const selected = selectedComment?.location;
if (selected?.kind === 'pdf' && selected.page === pageNumber) {
const regions: number[][] = [];
if (selected.quads?.length)
for (let i = 0; i + 7 < selected.quads.length; i += 8) {
const q = selected.quads.slice(i, i + 8);
regions.push([
Math.min(q[0], q[2], q[4], q[6]),
Math.min(q[1], q[3], q[5], q[7]),
Math.max(q[0], q[2], q[4], q[6]),
Math.max(q[1], q[3], q[5], q[7])
]);
}
else regions.push(selected.rect);
for (const region of regions) {
if (region.length !== 4 || !region.every(Number.isFinite)) continue;
const r = viewport.convertToViewportRectangle(region);
const highlight = document.createElement('div');
highlight.dataset.selectedAnnotation = selectedComment!.id;
Object.assign(highlight.style, {
position: 'absolute',
left: `${Math.min(r[0], r[2])}px`,
top: `${Math.min(r[1], r[3])}px`,
width: `${Math.max(3, Math.abs(r[2] - r[0]))}px`,
height: `${Math.max(3, Math.abs(r[3] - r[1]))}px`,
outline: '2px solid #2563eb',
background: 'rgba(250,204,21,.15)'
});
layer.appendChild(highlight);
}
}
wrapper.appendChild(layer);
}
};
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
@ -78,7 +278,7 @@
},
beforeMouseDown: (e) => {
// Only allow drag-to-pan when zoomed in (not at default scale)
if ((e?.target as HTMLElement | null)?.closest?.('.textLayer')) {
if ((e?.target as HTMLElement | null)?.closest?.('.textLayer, .file-comment-layer')) {
return true;
}
const transform = pzInstance?.getTransform();
@ -256,9 +456,6 @@
if (!pdfDoc || !sceneElement) return;
const token = ++renderToken;
// Clear previous content
sceneElement.innerHTML = '';
cancelTextLayers();
const pdfjs = await import('pdfjs-dist');
@ -333,6 +530,7 @@
}
sceneElement.replaceChildren(...wrappers);
void renderCommentMarkers();
lastRenderedZoom = 1;
renderedPage = singlePage ? selectedPage : 0;
initPanzoom();
@ -396,7 +594,7 @@
if (!url && !data) return;
const source = data ?? url;
if (source === loadedSource && pdfDoc) return;
if (source === loadedSource) return;
const token = ++loadToken;
loadedSource = source;
loading = true;
@ -407,6 +605,10 @@
cancelTextLayers();
pdfDoc?.destroy();
pdfDoc = null;
textRuns = null;
mappedDoc = null;
mappedSource = null;
comments = [];
try {
const pdfjs = await import('pdfjs-dist');
@ -424,6 +626,20 @@
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
if (token !== loadToken) return;
pageCount = pdfDoc.numPages;
if (onComments) {
const doc = pdfDoc;
void readPdfComments(doc, () => token !== loadToken)
.then((result) => {
if (token !== loadToken) return;
comments = result.comments;
onComments?.(result);
onAnchors?.(comments.filter((c) => !c.parentId).map((c) => c.id));
void renderCommentMarkers();
})
.catch(() => {
if (token === loadToken) onCommentsError?.();
});
}
activePage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
targetPage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
await renderAllPages();
@ -431,6 +647,7 @@
if (token === loadToken) {
console.error('PDF render error:', e);
error = 'Failed to load PDF.';
onCommentsError?.();
}
} finally {
if (token === loadToken) loading = false;
@ -439,7 +656,17 @@
onMount(() => {
mounted = true;
loadPdf();
resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (width <= 0 || height <= 0 || (width === lastWidth && height === lastHeight)) return;
lastWidth = width;
lastHeight = height;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (!loading && pdfDoc) void renderAllPages();
}, 100);
});
resizeObserver.observe(outerContainer.parentElement!);
});
$: if (mounted && (data || url)) {
@ -454,7 +681,14 @@
void scrollToTargetPage();
}
$: if (sourceComments && pdfDoc) void mapSourceComments();
$: if (selectedComment && !loading) void renderCommentMarkers();
onDestroy(() => {
markerToken++;
resizeObserver?.disconnect();
clearTimeout(resizeTimer);
loadToken++;
renderToken++;
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
@ -489,6 +723,7 @@
tabindex="0"
on:scroll={handleScroll}
on:wheel|nonpassive={handleWheel}
on:dblclick|capture|stopPropagation={() => {}}
on:pointerdown={focusViewer}
on:keydown={handleKeyDown}
>
@ -687,4 +922,9 @@
:global(.textLayer.selecting .endOfContent) {
top: 0;
}
/* pdfjs leaves its text-measuring canvas in <body> at 300x150 for PDFs without text */
:global(.hiddenCanvasElement) {
display: none;
}
</style>

View file

@ -3,6 +3,7 @@
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import Spinner from './Spinner.svelte';
import PDFViewer from './PDFViewer.svelte';
import type { FileComment, CommentResult } from '$lib/utils/comments/types';
export let data: ArrayBuffer | Uint8Array | null = null;
export let currentSlide = 0;
@ -11,12 +12,19 @@
export let singlePage = false;
export let itemLabel = 'Page';
export let listLabel = 'Pages';
export let onComments: ((result: CommentResult) => void) | null = null;
export let onCommentsError: (() => void) | null = null;
export let onCommentSelect: ((comment: FileComment) => void) | null = null;
export let selectedComment: FileComment | null = null;
export let sourceComments: FileComment[] | null = null;
export let onAnchors: ((ids: string[]) => void) | null = null;
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
let rootEl: HTMLDivElement;
let pdfViewerRef: PDFViewer;
let viewerData: ArrayBuffer | Uint8Array | null = null;
let loadedData: ArrayBuffer | Uint8Array | null = null;
let pageTarget: number | null = null;
let thumbsLoading = false;
let thumbnails: string[] = [];
@ -95,10 +103,12 @@
}
};
$: if (data) {
$: if (data && data !== loadedData) {
loadedData = data;
viewerData = copyPdfData(data);
void loadThumbnails(copyPdfData(data));
} else {
} else if (!data) {
loadedData = null;
viewerData = null;
thumbnails = [];
}
@ -192,6 +202,12 @@
{singlePage}
{itemLabel}
onPageChange={handlePageChange}
{onComments}
{onCommentsError}
{onCommentSelect}
{selectedComment}
{sourceComments}
{onAnchors}
className="w-full h-full"
/>
{:else}

View file

@ -2,6 +2,8 @@
import { getContext, onDestroy, onMount, tick } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import { clampDocumentTargetPage } from '$lib/utils/documentPreview';
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
import type { FileComment } from '$lib/utils/comments/types';
const i18n: any = getContext('i18n');
@ -11,6 +13,17 @@
export let targetPage: number | null = null;
export let itemLabel = 'Slide';
export let listLabel = 'Slides';
export let comments: FileComment[] = [];
export let selectedComment: FileComment | null = null;
export let onCommentSelect: ((comment: FileComment) => void) | null = null;
$: slideComments = comments.filter(
(c) =>
c.location?.kind === 'slide' &&
c.location.slide === safeSlide + 1 &&
c.location.x !== undefined &&
c.location.y !== undefined &&
!c.parentId
);
let rootEl: HTMLDivElement;
let stageEl: HTMLElement;
@ -79,7 +92,8 @@
pinchSpeed: 3.5,
filterKey: (e?: KeyboardEvent) => !!e && slideShortcutKeys.includes(e.key),
beforeWheel: () => true,
beforeMouseDown: () => {
beforeMouseDown: (event?: MouseEvent) => {
if ((event?.target as HTMLElement)?.closest('[data-slide-comment]')) return true;
const transform = pzInstance?.getTransform();
return !!transform && Math.abs(transform.scale - 1) < 0.01;
}
@ -280,15 +294,17 @@
{/each}
</aside>
<!-- svelte-ignore a11y_no_static_element_interactions (Preserve native double-click behavior without changing touch zoom.) -->
<section
bind:this={stageEl}
class="min-w-0 min-h-0 overflow-hidden flex items-center justify-center overscroll-contain"
on:wheel|nonpassive={handleStageWheel}
on:dblclick|capture|stopPropagation={() => {}}
>
{#if selectedSlide}
<div
bind:this={sceneEl}
class="shrink-0"
class="shrink-0 relative"
style="width: {slideWidth}px; height: {slideHeight}px;"
>
<img
@ -299,6 +315,23 @@
draggable="false"
on:load={onSlideLoad}
/>
{#each slideComments as comment, index (comment.id)}
{#if comment.location?.kind === 'slide'}
<button
type="button"
data-slide-comment={comment.id}
data-file-comment-id={comment.id}
class="absolute flex size-5 items-center justify-center rounded bg-white/95 text-gray-400 hover:text-gray-600 dark:bg-gray-850/95 dark:text-gray-500 dark:hover:text-gray-300 focus-visible:outline focus-visible:outline-2"
class:ring-2={selectedComment?.id === comment.id ||
selectedComment?.parentId === comment.id}
style:left={`${comment.location.x !== undefined ? Math.max(0, Math.min(slideWidth - 24, comment.location.x * fitScale)) : 8}px`}
style:top={`${comment.location.y !== undefined ? Math.max(0, Math.min(slideHeight - 24, comment.location.y * fitScale)) : 8 + index * 28}px`}
aria-label={`${$i18n.t('Comment')}: ${comment.author || $i18n.t('Unknown author')}`}
on:click={() => onCommentSelect?.(comment)}
><Icon name="chat-bubble" size={12} strokeWidth={1.5} /></button
>
{/if}
{/each}
</div>
{/if}
</section>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount } from 'svelte';
import type { HTMLTextareaAttributes } from 'svelte/elements';
export let value = '';
export let placeholder = '';
@ -8,6 +9,12 @@
export let maxSize: number | null = null;
export let required = false;
export let readonly = false;
export let disabled = false;
export let id: string | undefined = undefined;
export let autocomplete: HTMLTextareaAttributes['autocomplete'] = undefined;
export let spellcheck: boolean | undefined = undefined;
export let autocapitalize: HTMLTextareaAttributes['autocapitalize'] = undefined;
export let onKeydown: (event: KeyboardEvent) => void = () => {};
export let className =
'w-full rounded-lg px-3.5 py-2 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden h-full';
export let ariaLabel: string | null = null;
@ -17,20 +24,11 @@
let textareaElement: HTMLTextAreaElement | null = null;
// Adjust height on mount and after setting the element.
onMount(async () => {
await tick();
// The bound element exists on mount. Cancel the layout callback on navigation.
onMount(() => {
resize();
requestAnimationFrame(() => {
// setInterveal to cehck until textareaElement is set
const interval = setInterval(() => {
if (textareaElement) {
clearInterval(interval);
resize();
}
}, 100);
});
const frame = requestAnimationFrame(resize);
return () => cancelAnimationFrame(frame);
});
const resize = () => {
@ -82,6 +80,12 @@
{rows}
{required}
{readonly}
{disabled}
{id}
{autocomplete}
{spellcheck}
{autocapitalize}
on:keydown={onKeydown}
on:input={(e) => {
resize();

View file

@ -1,11 +1,54 @@
import type { ChangeSpec, Text } from '@codemirror/state';
import type { Chunk, DiffConfig } from '@codemirror/merge';
import { StateEffect, StateField, type ChangeSpec, type Text } from '@codemirror/state';
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view';
import { Chunk, type DiffConfig } from '@codemirror/merge';
// A scanLimit also gives up based on document length, even for sparse, tiny edits.
export const diffEditorConfig: DiffConfig = { timeout: 100 };
export type MergeDirection = 'to-working' | 'to-proposal';
// Match rows by the text preserved inside a chunk, not their ordinal position.
export function changedLinePairs(a: Text, b: Text, chunk: Chunk): [number, number][] {
const candidates = new Map<number, Map<number, { a: number; b: number; weight: number }>>();
let fromA = chunk.fromA;
let fromB = chunk.fromB;
for (let index = 0; index <= chunk.changes.length; index++) {
const change = chunk.changes[index];
const endA = change ? chunk.fromA + change.fromA : Math.min(chunk.toA, a.length);
const endB = change ? chunk.fromB + change.fromB : Math.min(chunk.toB, b.length);
while (fromA < endA && fromB < endB) {
const lineA = a.lineAt(fromA),
lineB = b.lineAt(fromB);
const length = Math.min(endA - fromA, endB - fromB, lineA.to - fromA, lineB.to - fromB);
const weight = a.sliceString(fromA, fromA + length).replace(/\s/g, '').length;
if (weight) {
let row = candidates.get(lineA.from);
if (!row) candidates.set(lineA.from, (row = new Map()));
const pair = row.get(lineB.from) ?? { a: lineA.from, b: lineB.from, weight: 0 };
pair.weight += weight;
row.set(lineB.from, pair);
}
fromA += length || 1;
fromB += length || 1;
}
if (change) {
fromA = chunk.fromA + change.toA;
fromB = chunk.fromB + change.toB;
}
}
const bestA = new Map<number, { a: number; b: number; weight: number }>();
const bestB = new Map<number, { a: number; b: number; weight: number }>();
for (const row of candidates.values()) {
for (const pair of row.values()) {
if (pair.weight > (bestA.get(pair.a)?.weight ?? 0)) bestA.set(pair.a, pair);
if (pair.weight > (bestB.get(pair.b)?.weight ?? 0)) bestB.set(pair.b, pair);
}
}
return [...bestA.values()]
.filter((pair) => bestB.get(pair.b) === pair)
.map((pair) => [pair.a, pair.b]);
}
export function changedLineCounts(a: Text, b: Text, chunks: readonly Chunk[]) {
const count = (doc: Text, side: 'A' | 'B') => {
let total = 0;
@ -90,3 +133,146 @@ export function selectedChunk(chunks: readonly Chunk[], side: 'a' | 'b', positio
return position >= from && (position < to || position === from);
});
}
export function visibleDiffChunks(
a: Text,
b: Text,
chunks: readonly Chunk[],
ignoreWhitespace: boolean,
ignoreEmphasis = false
) {
if (!ignoreWhitespace && !ignoreEmphasis) return chunks;
const ignored = ignoreWhitespace ? (ignoreEmphasis ? /[\s_*]/g : /\s/g) : /[_*]/g;
return chunks.flatMap((chunk) => {
const changes = chunk.changes.filter(
(change) =>
a.sliceString(chunk.fromA + change.fromA, chunk.fromA + change.toA).replace(ignored, '') !==
b.sliceString(chunk.fromB + change.fromB, chunk.fromB + change.toB).replace(ignored, '')
);
return changes.length
? [new Chunk(changes, chunk.fromA, chunk.toA, chunk.fromB, chunk.toB, chunk.precise)]
: [];
});
}
export type CollapsedPair = {
a: { from: number; to: number; lines: number };
b: { from: number; to: number; lines: number };
};
export function unchangedRanges(a: Text, b: Text, chunks: readonly Chunk[]): CollapsedPair[] {
const result: CollapsedPair[] = [];
let previousA = 0,
previousB = 0;
for (let index = 0; index <= chunks.length; index++) {
const chunk = chunks[index];
const range = (doc: Text, previous: number, next: number | undefined) => {
const first = index ? doc.lineAt(Math.min(previous, doc.length)).number + 3 : 1;
const last = next === undefined ? doc.lines : doc.lineAt(next).number - 4;
return last - first >= 3
? { from: doc.line(first).from, to: doc.line(last).to, lines: last - first + 1 }
: null;
};
const left = range(a, previousA, chunk?.fromA),
right = range(b, previousB, chunk?.fromB);
if (left && right) result.push({ a: left, b: right });
if (chunk) {
previousA = chunk.toA;
previousB = chunk.toB;
}
}
return result;
}
class CollapsedLines extends WidgetType {
constructor(
readonly label: string,
readonly expand: () => void
) {
super();
}
toDOM() {
const button = document.createElement('button');
button.type = 'button';
button.className = 'cm-collapsedLines w-full text-left';
button.textContent = this.label;
button.addEventListener('click', this.expand);
return button;
}
get estimatedHeight() {
return 27;
}
ignoreEvent() {
return true;
}
}
export const setDiffPresentation = StateEffect.define<DecorationSet>();
const presentation = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, transaction) {
for (const effect of transaction.effects)
if (effect.is(setDiffPresentation)) return effect.value;
return value.map(transaction.changes);
},
provide: (field) => EditorView.decorations.from(field)
});
export const diffPresentation = [
presentation,
EditorView.atomicRanges.of((view) =>
view.state
.field(presentation)
.update({ filter: (_from, _to, decoration) => !!decoration.spec.block })
)
];
export function presentationDecorations(
doc: Text,
side: 'a' | 'b',
chunks: readonly Chunk[],
ignoreWhitespace: boolean,
ignoreEmphasis: boolean,
folds: readonly CollapsedPair[],
label: (lines: number) => string,
expand: (pair: CollapsedPair) => void
) {
const decorations = [];
if (ignoreWhitespace || ignoreEmphasis) {
const visible = ignoreWhitespace ? (ignoreEmphasis ? /[^\s_*]+/g : /\S+/g) : /[^_*]+/g;
const rows = new Set<number>();
for (const chunk of chunks) {
const offset = side === 'a' ? chunk.fromA : chunk.fromB;
for (const change of chunk.changes) {
const from = offset + (side === 'a' ? change.fromA : change.fromB);
const to = offset + (side === 'a' ? change.toA : change.toB);
for (const match of doc.sliceString(from, to).matchAll(visible)) {
const start = from + match.index;
decorations.push(
Decoration.mark({ class: 'cm-visibleChangedText' }).range(
start,
start + match[0].length
)
);
for (
let line = doc.lineAt(start).number;
line <= doc.lineAt(start + match[0].length - 1).number;
line++
)
rows.add(doc.line(line).from);
}
}
}
for (const from of rows)
decorations.push(Decoration.line({ class: 'cm-visibleChangedLine' }).range(from));
}
for (const pair of folds) {
const range = pair[side];
decorations.push(
Decoration.replace({
block: true,
widget: new CollapsedLines(label(range.lines), () => expand(pair))
}).range(range.from, range.to)
);
}
return Decoration.set(decorations, true);
}

View file

@ -7,16 +7,19 @@ import {
type DecorationSet
} from '@codemirror/view';
import type { Chunk } from '@codemirror/merge';
import { changedLinePairs } from './diffEditor';
export function pairedLinePosition(a: Text, b: Text, chunks: readonly Chunk[], position: number) {
let offset = 0;
for (const chunk of chunks) {
if (position < chunk.fromA) break;
if (position < chunk.toA) {
const row = a.lineAt(position).number - a.lineAt(chunk.fromA).number;
const start = b.lineAt(Math.min(chunk.fromB, b.length)).number;
const end = b.lineAt(Math.min(Math.max(chunk.fromB, chunk.toB - 1), b.length)).number;
return b.line(Math.min(start + row, end)).from;
const pairs = changedLinePairs(a, b, chunk);
const line = a.lineAt(position).from;
return (
(pairs.find(([from]) => from >= line) ?? pairs.at(-1))?.[1] ??
Math.min(chunk.fromB, b.length)
);
}
offset = chunk.toB - chunk.toA;
}
@ -128,23 +131,7 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly
align(posA, posB);
}
if (!chunk) break;
if (chunk.fromA < chunk.toA && chunk.fromB < chunk.toB) {
const lineA = a.state.doc.lineAt(chunk.fromA).number,
lineB = b.state.doc.lineAt(chunk.fromB).number;
let row = Math.max(
0,
a.state.doc.lineAt(a.viewport.from).number - lineA,
b.state.doc.lineAt(b.viewport.from).number - lineB
);
while (lineA + row <= a.state.doc.lines && lineB + row <= b.state.doc.lines) {
const posA = a.state.doc.line(lineA + row).from,
posB = b.state.doc.line(lineB + row).from;
if (posA >= chunk.toA || posB >= chunk.toB || posA > a.viewport.to || posB > b.viewport.to)
break;
align(posA, posB);
row++;
}
}
for (const [posA, posB] of changedLinePairs(a.state.doc, b.state.doc, chunk)) align(posA, posB);
fromA = chunk.toA;
fromB = chunk.toB;
}

View file

@ -0,0 +1,48 @@
import { EditorState, Transaction } from '@codemirror/state';
import { isolateHistory, undoDepth } from '@codemirror/commands';
type Side = 'a' | 'b';
// Keep the panes' native undo groups in chronological order.
export class DiffUndoHistory {
private order: { side: Side; depth: number }[] = [];
private lastSide: Side | null = null;
get nextSide(): Side | null {
return this.order.at(-1)?.side ?? null;
}
extension(side: Side) {
return EditorState.transactionExtender.of((transaction) => {
if (
transaction.docChanged &&
this.lastSide !== side &&
!transaction.isUserEvent('undo') &&
!transaction.isUserEvent('redo') &&
!transaction.annotation(isolateHistory)
)
return { annotations: isolateHistory.of('before') };
return null;
});
}
record(side: Side, transaction: Transaction) {
if (!transaction.docChanged || transaction.annotation(Transaction.addToHistory) === false)
return;
this.lastSide = side;
const before = undoDepth(transaction.startState);
const after = undoDepth(transaction.state);
if (transaction.isUserEvent('undo')) {
this.order = this.order.filter((entry) => entry.side !== side || entry.depth <= after);
return;
}
// Native history periodically drops its oldest groups when it reaches its limit.
if (after < before) {
for (const entry of this.order) if (entry.side === side) entry.depth -= before + 1 - after;
}
this.order = this.order.filter(
(entry) => entry.side !== side || (entry.depth > 0 && entry.depth < after)
);
if (after) this.order.push({ side, depth: after });
}
}

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