mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-18 04:43:55 +00:00
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
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:
commit
91ea4e3421
209 changed files with 25223 additions and 3284 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -311,4 +311,7 @@ cypress/screenshots
|
|||
|
||||
|
||||
|
||||
/static/*
|
||||
/static/*
|
||||
# Generated and checksum-verified crypto distribution
|
||||
src/lib/encryption/vendor/
|
||||
crypto/encryption/target/
|
||||
|
|
|
|||
59
CHANGELOG.md
59
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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']}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
442
backend/open_webui/routers/search.py
Normal file
442
backend/open_webui/routers/search.py
Normal 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))
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {})),
|
||||
|
|
|
|||
432
backend/open_webui/socket/encryption.py
Normal file
432
backend/open_webui/socket/encryption.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
1
crypto/encryption/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target/
|
||||
2026
crypto/encryption/Cargo.lock
generated
Normal file
2026
crypto/encryption/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
28
crypto/encryption/Cargo.toml
Normal file
28
crypto/encryption/Cargo.toml
Normal 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"
|
||||
4
crypto/encryption/rust-toolchain.toml
Normal file
4
crypto/encryption/rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "1.95.0"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
profile = "minimal"
|
||||
267
crypto/encryption/src/lib.rs
Normal file
267
crypto/encryption/src/lib.rs
Normal 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
6
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
71
scripts/encryption/artifact.mjs
Normal file
71
scripts/encryption/artifact.mjs
Normal 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
138
scripts/encryption/build.py
Normal 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
|
||||
)
|
||||
113
src/app.css
113
src/app.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
36
src/lib/apis/search/index.ts
Normal file
36
src/lib/apis/search/index.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
404
src/lib/components/chat/FileNav/CommentFilePreview.svelte
Normal file
404
src/lib/components/chat/FileNav/CommentFilePreview.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
354
src/lib/components/chat/FileNav/MarkdownDocument.svelte
Normal file
354
src/lib/components/chat/FileNav/MarkdownDocument.svelte
Normal 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>
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}}')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(', ')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -246,8 +246,10 @@
|
|||
}
|
||||
|
||||
saveSettings({
|
||||
imageCompressionSize: imageCompressionSize,
|
||||
fontFamily
|
||||
...(isDefaultSetting('imageCompressionSize') ? {} : { imageCompressionSize }),
|
||||
...(isDefaultSetting('fontFamily') && fontFamily === defaultFontFamily()
|
||||
? {}
|
||||
: { fontFamily })
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
48
src/lib/components/common/diffEditorHistory.ts
Normal file
48
src/lib/components/common/diffEditorHistory.ts
Normal 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
Loading…
Add table
Reference in a new issue