Merge pull request #4656 from SillyTavern/staging Staging

74c158bd2e98b8b4dc54d2bb0d088c5a5e918826

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
103 files changed, +4364 -1528Ignore whitespace
.github/pr-auto-comments.yml+10 -0
@@ -41,6 +41,16 @@ labels:
4141 🔬 This PR needs testing!
4242 Any contributor can test and leave reviews, so feel free to help us out!
4343
44+ - name: ❗ Against Release Branch
45+ labeled:
46+ pr:
47+ body: >
48+ ❗ This PR is against the `release` branch.
49+
50+ Please make sure this was intended, and you did not want to target the `staging` branch. Only hotfixes, readme changes and similar should be made against `release`.
51+
52+ You can change the target branch **without recreating the PR** by clicking "Edit" at the top of the page.
53+
4454 - name: 🟥 ⬤⬤⬤⬤⬤
4555 labeled:
4656 pr:
.github/pull_request_template.md+1 -1
@@ -2,4 +2,4 @@
22
33## Checklist:
44
55- [ ] I have read the [ContributingContribution guidelines](https://github.com/SillyTavern/SillyTavern/blob/release/CONTRIBUTING.md).
.github/workflows/issues-auto-manager.yml+54 -9
@@ -15,8 +15,19 @@ jobs:
1515 label-on-content:
1616 name: 🏷️ Label Issues by Content
1717 runs-on: ubuntu-latest
18+ if: always()
1819
1920 steps:
21+ - name: Mint App Token
22+ id: app
23+ # Create a GitHub App token
24+ # https://github.com/marketplace/actions/create-github-app-token
25+ uses: actions/create-github-app-token@v2
26+ with:
27+ app-id: ${{ vars.ST_BOT_APP_ID }}
28+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
29+ owner: ${{ github.repository_owner }}
30+
2031 - name: Checkout Repository
2132 # Checkout
2233 # https://github.com/marketplace/actions/checkout
@@ -32,13 +43,25 @@ jobs:
3243 with:
3344 configuration-path: .github/issues-auto-labels.yml
3445 enable-versioned-regex: 0
3546 repo-token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
3647
3748 label-on-labels:
3849 name: 🏷️ Label Issues by Labels
50+ needs: [label-on-content]
3951 runs-on: ubuntu-latest
52+ if: always()
4053
4154 steps:
55+ - name: Mint App Token
56+ id: app
57+ # Create a GitHub App token
58+ # https://github.com/marketplace/actions/create-github-app-token
59+ uses: actions/create-github-app-token@v2
60+ with:
61+ app-id: ${{ vars.ST_BOT_APP_ID }}
62+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
63+ owner: ${{ github.repository_owner }}
64+
4265 - name: ✅ Add "👍 Approved" for relevant labels
4366 if: contains(fromJSON('["👩‍💻 Good First Issue", "🙏 Help Wanted", "🪲 Confirmed", "⚠️ High Priority", "❕ Medium Priority", "💤 Low Priority"]'), github.event.label.name)
4467 # 🤖 Issues Helper
@@ -46,7 +69,7 @@ jobs:
4669 uses: actions-cool/issues-helper@v3.6.0
4770 with:
4871 actions: 'add-labels'
4972 token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
5073 labels: '👍 Approved'
5174
5275 - name: ❌ Remove progress labels when issue is marked done or stale
@@ -56,7 +79,7 @@ jobs:
5679 uses: actions-cool/issues-helper@v3.6.0
5780 with:
5881 actions: 'remove-labels'
5982 token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
6083 labels: '🧑‍💻 In Progress,🤔 Unsure,🤔 Under Consideration'
6184
6285 - name: ❌ Remove temporary labels when confirmed labels are added
@@ -66,7 +89,7 @@ jobs:
6689 uses: actions-cool/issues-helper@v3.6.0
6790 with:
6891 actions: 'remove-labels'
6992 token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
7093 labels: '🤔 Unsure,🤔 Under Consideration'
7194
7295 - name: ❌ Remove no bug labels when "🪲 Confirmed" is added
@@ -76,32 +99,54 @@ jobs:
7699 uses: actions-cool/issues-helper@v3.6.0
77100 with:
78101 actions: 'remove-labels'
79102 token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
80103 labels: '✖️ Not Reproducible,✖️ Not A Bug'
81104
82105 remove-stale-label:
83106 name: 🗑️ Remove Stale Label on Comment
107+ needs: [label-on-content, label-on-labels]
84108 runs-on: ubuntu-latest
85109 # Only run this on new comments, to automatically remove the stale label
86110 if: always() && (github.event_name == 'issue_comment' && github.actorevent.sender.type != 'github-actions[bot]Bot')
87111
88112 steps:
113+ - name: Mint App Token
114+ id: app
115+ # Create a GitHub App token
116+ # https://github.com/marketplace/actions/create-github-app-token
117+ uses: actions/create-github-app-token@v2
118+ with:
119+ app-id: ${{ vars.ST_BOT_APP_ID }}
120+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
121+ owner: ${{ github.repository_owner }}
122+
89123 - name: Remove Stale Label
90124 # 🤖 Issues Helper
91125 # https://github.com/marketplace/actions/issues-helper
92126 uses: actions-cool/issues-helper@v3.6.0
93127 with:
94128 actions: 'remove-labels'
95129 token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
96130 issue-number: ${{ github.event.issue.number }}
97131 labels: '⚰️ Stale,🕸️ Inactive,🚏 Awaiting User Response,🛑 No Response'
98132
99133 write-auto-comments:
100134 name: 💬 Post Issue Comments Based on Labels
101135 needs: [label-on-content, label-on-labels, remove-stale-label]
102136 runs-on: ubuntu-latest
137+ if: always()
103138
104139 steps:
140+ - name: Mint App Token
141+ id: app
142+ # Create a GitHub App token
143+ # https://github.com/marketplace/actions/create-github-app-token
144+ uses: actions/create-github-app-token@v2
145+ with:
146+ app-id: ${{ vars.ST_BOT_APP_ID }}
147+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
148+ owner: ${{ github.repository_owner }}
149+
105150 - name: Checkout Repository
106151 # Checkout
107152 # https://github.com/marketplace/actions/checkout
@@ -113,4 +158,4 @@ jobs:
113158 uses: peaceiris/actions-label-commenter@v1.10.0
114159 with:
115160 config_file: .github/issues-auto-comments.yml
116161 github_token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
.github/workflows/issues-updates-on-merge.yml+12 -1
@@ -15,8 +15,19 @@ jobs:
1515 update-linked-issues:
1616 name: 🔗 Mark Linked Issues Done on Push
1717 runs-on: ubuntu-latest
18+ if: always()
1819
1920 steps:
21+ - name: Mint App Token
22+ id: app
23+ # Create a GitHub App token
24+ # https://github.com/marketplace/actions/create-github-app-token
25+ uses: actions/create-github-app-token@v2
26+ with:
27+ app-id: ${{ vars.ST_BOT_APP_ID }}
28+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
29+ owner: ${{ github.repository_owner }}
30+
2031 - name: Checkout Repository
2132 # Checkout
2233 # https://github.com/marketplace/actions/checkout
@@ -31,7 +42,7 @@ jobs:
3142 - name: Label Linked Issues
3243 id: label_linked_issues
3344 env:
3445 GH_TOKEN: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
3546 run: |
3647 for ISSUE in $(echo $issues | jq -r '.[]'); do
3748 if [ "${{ github.ref }}" == "refs/heads/staging" ]; then
.github/workflows/job-close-stale.yml+38 -5
@@ -15,14 +15,25 @@ jobs:
1515 mark-inactivity:
1616 name: ⏳ Mark Issues/PRs without Activity
1717 runs-on: ubuntu-latest
18+ if: always()
1819
1920 steps:
21+ - name: Mint App Token
22+ id: app
23+ # Create a GitHub App token
24+ # https://github.com/marketplace/actions/create-github-app-token
25+ uses: actions/create-github-app-token@v2
26+ with:
27+ app-id: ${{ vars.ST_BOT_APP_ID }}
28+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
29+ owner: ${{ github.repository_owner }}
30+
2031 - name: Mark Issues/PRs without Activity
2132 # Close Stale Issues and PRs
2233 # https://github.com/marketplace/actions/close-stale-issues
2334 uses: actions/stale@v9.1.0
2435 with:
2536 repo-token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
2637 days-before-stale: 183
2738 days-before-close: 7
2839 operations-per-run: 30
@@ -47,16 +58,27 @@ jobs:
4758
4859 await-user-response:
4960 name: ⚠️ Mark Issues/PRs Awaiting User Response
61+ needs: [mark-inactivity]
5062 runs-on: ubuntu-latest
51- needs: mark-inactivity
63+ if: always()
5264
5365 steps:
66+ - name: Mint App Token
67+ id: app
68+ # Create a GitHub App token
69+ # https://github.com/marketplace/actions/create-github-app-token
70+ uses: actions/create-github-app-token@v2
71+ with:
72+ app-id: ${{ vars.ST_BOT_APP_ID }}
73+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
74+ owner: ${{ github.repository_owner }}
75+
5476 - name: Mark Issues/PRs Awaiting User Response
5577 # Close Stale Issues and PRs
5678 # https://github.com/marketplace/actions/close-stale-issues
5779 uses: actions/stale@v9.1.0
5880 with:
5981 repo-token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
6082 days-before-stale: 7
6183 days-before-close: 7
6284 operations-per-run: 30
@@ -74,16 +96,27 @@ jobs:
7496
7597 alternative-exists:
7698 name: 🔄 Mark Issues with Alternative Exists
99+ needs: [mark-inactivity, await-user-response]
77100 runs-on: ubuntu-latest
78- needs: await-user-response
101+ if: always()
79102
80103 steps:
104+ - name: Mint App Token
105+ id: app
106+ # Create a GitHub App token
107+ # https://github.com/marketplace/actions/create-github-app-token
108+ uses: actions/create-github-app-token@v2
109+ with:
110+ app-id: ${{ vars.ST_BOT_APP_ID }}
111+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
112+ owner: ${{ github.repository_owner }}
113+
81114 - name: Mark Issues with Alternative Exists
82115 # Close Stale Issues and PRs
83116 # https://github.com/marketplace/actions/close-stale-issues
84117 uses: actions/stale@v9.1.0
85118 with:
86119 repo-token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
87120 days-before-stale: 7
88121 days-before-close: 7
89122 operations-per-run: 30
.github/workflows/on-close-handler.yml+12 -1
@@ -15,14 +15,25 @@ jobs:
1515 remove-labels:
1616 name: 🗑️ Remove Pending Labels on Close
1717 runs-on: ubuntu-latest
18+ if: always()
1819
1920 steps:
21+ - name: Mint App Token
22+ id: app
23+ # Create a GitHub App token
24+ # https://github.com/marketplace/actions/create-github-app-token
25+ uses: actions/create-github-app-token@v2
26+ with:
27+ app-id: ${{ vars.ST_BOT_APP_ID }}
28+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
29+ owner: ${{ github.repository_owner }}
30+
2031 - name: Remove Pending Labels on Close
2132 # 🤖 Issues Helper
2233 # https://github.com/marketplace/actions/issues-helper
2334 uses: actions-cool/issues-helper@v3.6.0
2435 with:
2536 actions: remove-labels
2637 token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
2738 issue-number: ${{ github.event.issue.number || github.event.pull_request.number }}
2839 labels: '🚏 Awaiting User Response,🧑‍💻 In Progress,📌 Keep Open,🚫 Merge Conflicts,🔬 Needs Testing,🔨 Needs Work,⚰️ Stale,⛔ Waiting For External/Upstream'
.github/workflows/on-open-handler.yml+12 -2
@@ -15,15 +15,25 @@ jobs:
1515 label-maintainer:
1616 name: 🏷️ Label if Author is a Repo Maintainer
1717 runs-on: ubuntu-latest
1818 if: always() && contains(fromJson('["Cohee1207", "RossAscends", "Wolfsblvt"]'), github.actor)
1919
2020 steps:
21+ - name: Mint App Token
22+ id: app
23+ # Create a GitHub App token
24+ # https://github.com/marketplace/actions/create-github-app-token
25+ uses: actions/create-github-app-token@v2
26+ with:
27+ app-id: ${{ vars.ST_BOT_APP_ID }}
28+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
29+ owner: ${{ github.repository_owner }}
30+
2131 - name: Label if Author is a Repo Maintainer
2232 # 🤖 Issues Helper
2333 # https://github.com/marketplace/actions/issues-helper
2434 uses: actions-cool/issues-helper@v3.6.0
2535 with:
2636 actions: 'add-labels'
2737 token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
2838 issue-number: ${{ github.event.issue.number || github.event.pull_request.number }}
2939 labels: '👷 Maintainer'
.github/workflows/pr-auto-manager.yml+88 -16
@@ -16,7 +16,7 @@ jobs:
1616 name: ✅ Check ESLint on PR
1717 runs-on: ubuntu-latest
1818 # Only needs to run when code is changed
1919 if: always() && (github.event.action == 'opened' || github.event.action == 'synchronize')
2020
2121 # Override permissions, linter likely needs write access to issues
2222 permissions:
@@ -48,7 +48,7 @@ jobs:
4848 # https://github.com/marketplace/actions/action-eslint
4949 uses: sibiraj-s/action-eslint@v3.0.1
5050 with:
5151 token: ${{ secrets.GITHUB_TOKEN }} # ESLint can run with the original permissions
5252 eslint-args: '--ignore-path=.gitignore --quiet'
5353 extensions: 'js'
5454 annotations: true
@@ -71,12 +71,22 @@ jobs:
7171 pull-requests: write
7272
7373 steps:
74+ - name: Mint App Token
75+ id: app
76+ # Create a GitHub App token
77+ # https://github.com/marketplace/actions/create-github-app-token
78+ uses: actions/create-github-app-token@v2
79+ with:
80+ app-id: ${{ vars.ST_BOT_APP_ID }}
81+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
82+ owner: ${{ github.repository_owner }}
83+
7484 - name: Label PR Size
7585 # Pull Request Size Labeler
7686 # https://github.com/marketplace/actions/pull-request-size-labeler
7787 uses: codelytv/pr-size-labeler@v1.10.2
7888 with:
7989 GITHUB_TOKEN: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
8090 xs_label: '🟩 ⬤○○○○'
8191 xs_max_size: '20'
8292 s_label: '🟩 ⬤⬤○○○'
@@ -95,9 +105,19 @@ jobs:
95105 name: 🏷️ Label PR by Branches
96106 runs-on: ubuntu-latest
97107 # Only label once when PR is created or when base branch is changed, to allow manual label removal
98108 if: always() && (github.event.action == 'opened' || (github.event.action == 'synchronize' && github.event.changes.base))
99109
100110 steps:
111+ - name: Mint App Token
112+ id: app
113+ # Create a GitHub App token
114+ # https://github.com/marketplace/actions/create-github-app-token
115+ uses: actions/create-github-app-token@v2
116+ with:
117+ app-id: ${{ vars.ST_BOT_APP_ID }}
118+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
119+ owner: ${{ github.repository_owner }}
120+
101121 - name: Checkout Repository
102122 # Checkout
103123 # https://github.com/marketplace/actions/checkout
@@ -109,15 +129,26 @@ jobs:
109129 uses: actions/labeler@v5.0.0
110130 with:
111131 configuration-path: .github/pr-auto-labels-by-branch.yml
112132 repo-token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
113133
114134 label-by-files:
115135 name: 🏷️ Label PR by Files
136+ needs: [label-by-branches]
116137 runs-on: ubuntu-latest
117138 # Only needs to run when code is changed
118139 if: always() && (github.event.action == 'opened' || github.event.action == 'synchronize')
119140
120141 steps:
142+ - name: Mint App Token
143+ id: app
144+ # Create a GitHub App token
145+ # https://github.com/marketplace/actions/create-github-app-token
146+ uses: actions/create-github-app-token@v2
147+ with:
148+ app-id: ${{ vars.ST_BOT_APP_ID }}
149+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
150+ owner: ${{ github.repository_owner }}
151+
121152 - name: Checkout Repository
122153 # Checkout
123154 # https://github.com/marketplace/actions/checkout
@@ -127,15 +158,18 @@ jobs:
127158 # Pull Request Labeler
128159 # https://github.com/marketplace/actions/labeler
129160 uses: actions/labeler@v5.0.0
161+ env:
162+ GITHUB_TOKEN: ${{ steps.app.outputs.token }} # labeler action needs some handholding
130163 with:
131164 configuration-path: .github/pr-auto-labels-by-files.yml
132165 repo-token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
133166
134167 remove-stale-label:
135168 name: 🗑️ Remove Stale Label on Comment
169+ needs: [label-by-branches, label-by-files]
136170 runs-on: ubuntu-latest
137171 # Only runs on comments not done by the github actions bot
138172 if: always() && (github.event_name == 'pull_request_review_comment' && github.actorevent.sender.type != 'github-actions[bot]Bot')
139173
140174 # Override permissions, issue labeler needs issues write access
141175 permissions:
@@ -144,19 +178,33 @@ jobs:
144178 pull-requests: write
145179
146180 steps:
181+ - name: Mint App Token
182+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
183+ id: app
184+ # Only run if the PR is from the same repository
185+ # This action runs on comments, which will not receive the env vars for this
186+ # Create a GitHub App token
187+ # https://github.com/marketplace/actions/create-github-app-token
188+ uses: actions/create-github-app-token@v2
189+ with:
190+ app-id: ${{ vars.ST_BOT_APP_ID }}
191+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
192+ owner: ${{ github.repository_owner }}
193+
147194 - name: Remove Stale Label
195+ if: always()
148196 # 🤖 Issues Helper
149197 # https://github.com/marketplace/actions/issues-helper
150198 uses: actions-cool/issues-helper@v3.6.0
151199 with:
152200 actions: 'remove-labels'
153- token: ${{ secrets.GITHUB_TOKEN }}
201+ token: ${{ steps.app.outputs.token || github.token }} # Use fallback to GITHUB_TOKEN if app token is not available
154202 issue-number: ${{ github.event.pull_request.number }}
155203 labels: '⚰️ Stale'
156204
157205 check-merge-blocking-labels:
158206 name: 🚫 Check Merge Blocking Labels
159207 needs: [label-by-branches, label-by-files, remove-stale-label]
160208 runs-on: ubuntu-latest
161209 # Run, even if the previous jobs were skipped/failed
162210 if: always()
@@ -206,12 +254,22 @@ jobs:
206254
207255 write-auto-comments:
208256 name: 💬 Post PR Comments Based on Labels
209257 needs: [label-by-branches, label-by-files, remove-stale-label, check-merge-blocking-labels]
210258 runs-on: ubuntu-latest
211259 # Run, even if the previous jobs were skipped/failed
212- if: always()
260+ if: always() && (github.event_name == 'pull_request_target')
213261
214262 steps:
263+ - name: Mint App Token
264+ id: app
265+ # Create a GitHub App token
266+ # https://github.com/marketplace/actions/create-github-app-token
267+ uses: actions/create-github-app-token@v2
268+ with:
269+ app-id: ${{ vars.ST_BOT_APP_ID }}
270+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
271+ owner: ${{ github.repository_owner }}
272+
215273 - name: Checkout Repository
216274 # Checkout
217275 # https://github.com/marketplace/actions/checkout
@@ -223,13 +281,17 @@ jobs:
223281 uses: peaceiris/actions-label-commenter@v1.10.0
224282 with:
225283 config_file: .github/pr-auto-comments.yml
226284 github_token: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
227285
228286 # This runs on merged PRs to staging, reading the PR body and directly linked issues. Check `issues-updates-on-merge.yml`:`update-linked-issues` for commit-based updates.
229287 update-linked-issues:
230288 name: 🔗 Mark Linked Issues Done on Staging Merge
231289 runs-on: ubuntu-latest
232- if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'staging'
290+ if: >
291+ always() &&
292+ github.event_name == 'pull_request_target' &&
293+ github.event.pull_request.merged == true &&
294+ github.event.pull_request.base.ref == 'staging'
233295
234296 # Override permissions, We need to be able to write to issues
235297 permissions:
@@ -238,6 +300,16 @@ jobs:
238300 pull-requests: write
239301
240302 steps:
303+ - name: Mint App Token
304+ id: app
305+ # Create a GitHub App token
306+ # https://github.com/marketplace/actions/create-github-app-token
307+ uses: actions/create-github-app-token@v2
308+ with:
309+ app-id: ${{ vars.ST_BOT_APP_ID }}
310+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
311+ owner: ${{ github.repository_owner }}
312+
241313 - name: Extract Linked Issues From PR Description
242314 id: extract_issues
243315 run: |
@@ -250,7 +322,7 @@ jobs:
250322 PR_NUMBER=${{ github.event.pull_request.number }}
251323 REPO=${{ github.repository }}
252324 API_URL="https://api.github.com/repos/$REPO/pulls/$PR_NUMBER/issues"
253325 ISSUES=$(curl -s -H "Authorization: token ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}" "$API_URL" | jq -r '.[].number' | jq -R -s -c 'split("\n")[:-1]')
254326 echo "linked_issues=$ISSUES" >> $GITHUB_ENV
255327
256328 - name: Merge Issue Lists
@@ -262,7 +334,7 @@ jobs:
262334 - name: Label Linked Issues
263335 id: label_linked_issues
264336 env:
265337 GH_TOKEN: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
266338 run: |
267339 for ISSUE in $(echo $final_issues | jq -r '.[]'); do
268340 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "✅ Done (staging)" --remove-label "🧑‍💻 In Progress"
.github/workflows/pr-check-merge-conflicts.yaml+12 -1
@@ -15,14 +15,25 @@ jobs:
1515 check-merge-conflicts:
1616 name: ⚔️ Check Merge Conflicts
1717 runs-on: ubuntu-latest
18+ if: always()
1819
1920 steps:
21+ - name: Mint App Token
22+ id: app
23+ # Create a GitHub App token
24+ # https://github.com/marketplace/actions/create-github-app-token
25+ uses: actions/create-github-app-token@v2
26+ with:
27+ app-id: ${{ vars.ST_BOT_APP_ID }}
28+ private-key: ${{ secrets.ST_BOT_PRIVATE_KEY }}
29+ owner: ${{ github.repository_owner }}
30+
2031 - name: Check Merge Conflicts
2132 # Label Conflicting Pull Requests
2233 # https://github.com/marketplace/actions/label-conflicting-pull-requests
2334 uses: eps1lon/actions-label-merge-conflict@v3.0.3
2435 with:
2536 dirtyLabel: '🚫 Merge Conflicts'
2637 repoToken: ${{ secretssteps.GITHUB_TOKENapp.outputs.token }}
2738 commentOnDirty: >
2839 ⚠️ This PR has conflicts that need to be resolved before it can be merged.
.gitignore+1 -0
@@ -55,3 +55,4 @@ public/scripts/extensions/third-party
5555.env
5656/StartDev.bat
5757yarn.lock
58+*.code-workspace
58 \ No newline at end of file
CONTRIBUTING.md+14 -3
@@ -9,7 +9,7 @@
99## Getting the code ready
1010
11111. Register a GitHub account.
12122. Fork this repository under your account.
13133. Clone the fork onto your machine.
14144. Open the cloned repository in the code editor.
15155. Create a git branch (recommended).
@@ -29,11 +29,22 @@
2929 - Updating GitHub Actions.
3030 - Hotfixing a critical bug.
31314. Project maintainers will test and can change your code before merging.
32-5. Write at least somewhat meaningful PR descriptions. There's no "right" way to do it, but the following may help with outlining a general structure:
32+5. To make sure that your contribution remains testable and reviewable, try not to exceed a soft limit of **200 lines of code** (both additions and deletions) per pull request. If you have more to contribute, split it into multiple pull requests. We can also consider creating a separate feature branch for more substantial changes, but please discuss it with the maintainers first.
33+6. Write at least somewhat meaningful PR descriptions and commit messages. There's no "right" way to do it, but the following may help with outlining a general structure:
3334 - What is the reason for a change?
3435 - What did you do to achieve this?
3536 - How would a reviewer test the change?
36-6. Mind the license. Your contributions will be licensed under the GNU Affero General Public License. If you don't know what that implies, consult your lawyer.
37+7. English is the primary language of communication in this project. Please use only English when writing commit messages, PR descriptions, comments and other text. This does not apply to contributions to localization files.
38+8. Mind the license. Your contributions will be licensed under the GNU Affero General Public License. If you don't know what that implies, consult your lawyer.
39+
40+## Use of AI coding assistance tools ("Vibe Coding")
41+
42+We do not prohibit nor encourage the use of AI tools for coding assistance to help you write code, documentation, etc. This includes specialized IDEs, plugins and add-ons, chat interfaces, etc. However, please keep in mind the following:
43+
44+- No matter who (or what) wrote the code, you are responsible for it. Make sure to carefully review and test everything before committing, and be ready to discuss and fix any issues that may arise during the review.
45+- Maintainers can reject reviewing and accepting PRs of very low quality, i.e. if the time to fix the issues exceeds the time to write the code from scratch.
46+- Avoid common mistakes attributed to AI tools, such as: adding/removing unrelated comments, excessive logging, unawareness of the project context and conventions, etc.
47+- You are allowed, but not required, to trigger AI tools that are added to the project by maintainers (Gemini, Copilot, Codex). Keep in mind that any feedback (comments, suggestions) that these tools generate is not a call to action; make sure to properly assess it before applying.
3748
3849## Further reading
3950
default/config.yaml+20 -8
@@ -82,18 +82,30 @@ requestProxy:
8282enableUserAccounts: false
8383# Enable discreet login mode: hides user list on the login screen
8484enableDiscreetLogin: false
85-# Enable's authlia based auto login. Only enable this if you
86-# have setup and installed Authelia as a middle-ware on your
87-# reverse proxy
88-# https://www.authelia.com/
89-# This will use auto login to an account with the same username
90-# as that used for authlia. (Ensure the username in authlia
91-# is an exact match in lowercase with that in sillytavern)
92-autheliaAuth: false
9385# If `basicAuthMode` and this are enabled then
9486# the username and passwords for basic auth are the same as those
9587# for the individual accounts
9688perUserBasicAuth: false
89+
90+# -- SSO LOGIN CONFIGURATION --
91+sso:
92+ # Enable's authlia based auto login. Only enable this if you
93+ # have setup and installed Authelia as a middle-ware on your
94+ # reverse proxy
95+ # https://www.authelia.com/
96+ # This will use auto login to an account with the same username
97+ # as that used for authlia. (Ensure the username in authlia
98+ # is an exact match in lowercase with that in sillytavern)
99+ autheliaAuth: false
100+ # Enable's authentik based auto login. Only enable this if you
101+ # have setup and installed Authentik as a middle-ware on your
102+ # reverse proxy.
103+ # https://goauthentik.io/
104+ # This will use auto login to an account with the same username
105+ # as that used for authentik. (Ensure the username in authentik
106+ # is an exact match in lowercase with that in sillytavern).
107+ authentikAuth: false
108+
97109# Host whitelist configuration. Recommended if you're using a listen mode
98110hostWhitelist:
99111 # Enable or disable host whitelisting
default/content/presets/openai/Default.json+6 -3
@@ -1,20 +1,23 @@
11{
22 "chat_completion_source": "openai",
33 "openai_model": "gpt-4-turbo",
44 "claude_model": "claude-3-5-sonnet-202406204-5",
55 "openrouter_model": "OR_Website",
66 "openrouter_use_fallback": false,
77 "openrouter_group_models": false,
88 "openrouter_sort_models": "alphabetically",
99 "ai21_model": "jamba-large",
1010 "mistralai_model": "mistral-large-latest",
11+ "electronhub_model": "gpt-4o-mini",
12+ "electronhub_sort_models": "alphabetically",
13+ "electronhub_group_models": false,
1114 "custom_model": "",
1215 "custom_url": "",
1316 "custom_include_body": "",
1417 "custom_exclude_body": "",
1518 "custom_include_headers": "",
1619 "google_model": "gemini-2.5-pro",
1720 "vertexai_model": "gemini-2.0-flash5-001pro",
1821 "temperature": 1,
1922 "frequency_penalty": 0,
2023 "presence_penalty": 0,
default/content/settings.json+1 -1
@@ -620,7 +620,7 @@
620620 },
621621 "wi_format": "{0}",
622622 "openai_model": "gpt-4-turbo",
623623 "claude_model": "claude-3-5-sonnet-202406204-5",
624624 "ai21_model": "jamba-large",
625625 "openrouter_model": "OR_Website",
626626 "reverse_proxy": "",
package-lock.json+2 -2
@@ -1,12 +1,12 @@
11{
22 "name": "sillytavern",
33 "version": "1.13.45",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "sillytavern",
99 "version": "1.13.45",
1010 "hasInstallScript": true,
1111 "license": "AGPL-3.0",
1212 "dependencies": {
package.json+1 -1
@@ -113,7 +113,7 @@
113113 "type": "git",
114114 "url": "https://github.com/SillyTavern/SillyTavern.git"
115115 },
116116 "version": "1.13.45",
117117 "scripts": {
118118 "start": "node server.js",
119119 "debug": "node --inspect server.js",
public/css/backgrounds.css+142 -69
@@ -1,6 +1,5 @@
11/* Main Page Backgrounds */
22#bg1, {
3-#bg_custom {
43 background-repeat: no-repeat;
54 background-attachment: fixed;
65 background-size: cover;
@@ -8,72 +7,46 @@
87 width: 100%;
98 height: 100%;
109 transition: background-image var(--animation-duration-3x) ease-in-out;
10+ z-index: -1;
1111}
1212
1313/* Fitting options */
1414#background_fitting {
1515 max-width: 6em8em;
1616}
1717
1818/* Fill/Cover - scales to fill width while maintaining aspect ratio */
1919#bg1.cover, {
20-#bg_custom.cover {
2120 background-size: cover;
2221 background-position: center;
2322}
2423
2524/* Fit/Contain - shows entire image maintaining aspect ratio */
2625#bg1.contain, {
27-#bg_custom.contain {
2826 background-size: contain;
2927 background-position: center;
3028 background-repeat: no-repeat;
3129}
3230
3331/* Stretch - stretches to fill entire space */
3432#bg1.stretch, {
35-#bg_custom.stretch {
3633 background-size: 100% 100%;
3734}
3835
3936/* Center - centers without scaling */
4037#bg1.center, {
41-#bg_custom.center {
4238 background-size: auto;
4339 background-position: center;
4440 background-repeat: no-repeat;
4541}
4642
47-body.reduced-motion #bg1,
48-body.reduced-motion #bg_custom {
49- transition: none;
50-}
51-
52-#bg1 {
53- background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
54- z-index: -3;
55-}
56-
57-#bg_custom {
58- background-image: none;
59- z-index: -2;
60-}
61-
62-.bg_example.flex-container.locked:not(:focus-visible) {
63- outline-color: var(--golden);
64-}
65-
6643/* This is the main flex container for the entire drawer */
6744#Backgrounds.drawer-content.openDrawer.bg-drawer-layout {
6845 display: flex;
46+}
47+
48+.bg-drawer-layout {
6949 flex-direction: column;
70- height: calc(100vh - var(--topBarBlockSize));
71- max-height: calc(100vh - var(--topBarBlockSize));
72- height: calc(100dvh - var(--topBarBlockSize));
73- max-height: calc(100dvh - var(--topBarBlockSize));
74- overflow: hidden;
75- width: var(--sheldWidth);
76- max-width: var(--sheldWidth);
7750 padding: 0;
7851}
7952
@@ -82,11 +55,31 @@ body.reduced-motion #bg_custom {
8255 padding: 5px;
8356 background-color: var(--SmartThemeBlurTintColor);
8457 border-bottom: 1px solid var(--SmartThemeBorderColor);
58+ width: 100%;
8559}
8660
87-#bg-header-fixed>.flex-container {
61+.bg-header-row-1,
62+.bg-header-row-2 {
63+ display: flex;
64+ gap: 5px;
65+ width: 100%;
66+}
67+
68+/* Control buttons in header */
69+.heading-container-with-controls {
70+ position: relative;
71+}
72+
73+.heading-container-with-controls .heading-text {
74+ margin: 10px 0;
75+}
76+
77+.heading-container-with-controls .heading-controls {
78+ position: absolute;
79+ right: 5px;
80+ top: 50%;
81+ transform: translateY(-50%);
8882 display: flex;
89- align-items: center;
9083 gap: 5px;
9184}
9285
@@ -94,49 +87,92 @@ body.reduced-motion #bg_custom {
9487 flex-grow: 1;
9588 overflow-y: auto;
9689 overflow-x: hidden;
9790 padding: 0 5px 5px15px;
91+ position: relative;
92+}
93+
94+#bg_menu_content,
95+#bg_custom_content {
96+ display: grid;
97+ gap: 5px;
98+ width: 100%;
99+ grid-template-columns: repeat(var(--bg-thumb-columns, 5), 1fr);
98100}
99101
100102#bg-filter {
101103 font-size: calc(var(--mainFontSize) * 0.95);
102104}
103105
104106/* Thumbnail Menu & ButtonsThumbnails */
107+.bg_example:hover .BGSampleTitle {
108+ opacity: 1;
109+}
110+
105111.bg_example .mobile-only-menu-toggle {
106112 display: none;
107113}
108114
109115.bg_example.flex-container {
110- width: 30%;
111- max-width: 200px;
112- margin: 5px;
113- aspect-ratio: 16/9;
114116 cursor: pointer;
115117 box-shadow: 0 0 7px var(--black50a);
116-
117118 position: relative;
118119 overflow: hidden;
119120 border-radius: 8px;
120121 border: 0px solid transparent;
121122 outline: 2px solid var(--SmartThemeBorderColor);
122123 outline-offset: -1px;
124+
125+ height: auto;
126+ aspect-ratio: 16 / 9;
123127}
124128
125129.bg_example.flex-container:focus-visible {
126130 outline-offset: inherit;
131+ outline-color: var(--interactable-outline-color);
132+}
133+
134+.bg_example.locked-background {
135+ outline: 2px solid var(--golden);
136+ outline-offset: 0;
127137}
128138
129-.bg_example_img {
139+.bg_example.locked-background::after {
140+ content: '\f023';
141+ font-family: 'Font Awesome 6 Free';
142+ font-weight: 900;
143+
130144 position: absolute;
131145 topbottom: -2px5px;
132146 leftright: -2px5px;
133147 rightz-index: -2px4;
134148 bottomcolor: var(-2px-golden);
149+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
150+ font-size: calc(var(--mainFontSize) * 0.8);
151+ pointer-events: none;
152+}
135153
136- background-image: inherit;
154+.bg_example:not(.locked-background) .jg-unlock,
155+.bg_example.locked-background .jg-lock {
156+ display: none;
157+}
137158
138- background-size: cover;
159+.bg_example.selected-background {
139- background-position: center;
160+ outline: 2px solid white;
161+ outline-offset: 0;
162+}
163+
164+.bg_example.selected-background::before {
165+ content: '\f00c';
166+ font-family: 'Font Awesome 6 Free';
167+ font-weight: 900;
168+ position: absolute;
169+ top: 5px;
170+ left: 5px;
171+ z-index: 4;
172+ color: var(--white100);
173+ filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8));
174+ font-size: calc(var(--mainFontSize) * 0.9);
175+ pointer-events: none;
140176}
141177
142178.bg_example .jg-menu {
@@ -145,9 +181,8 @@ body.reduced-motion #bg_custom {
145181 top: 2px;
146182 right: 2px;
147183 background-color: rgba(0, 0, 0, 0.5);
148184 border-radius: 8px5px;
149185 gappadding: 3px 3px;
150- padding: 3px 5px;
151186 z-index: 3;
152187 backdrop-filter: blur(4px);
153188 border: 1px solid var(--SmartThemeBorderColor);
@@ -170,14 +205,14 @@ body.reduced-motion #bg_custom {
170205
171206.bg_example .jg-button {
172207 display: flex;
173208 width: 30px24px;
174209 height: 30px24px;
175210 align-items: center;
176211 justify-content: center;
177212 color: white;
178213 padding: 5px;
179214 font-size: 1.1em;
180215 border-radius: 6px5px;
181216 transition: background-color var(--animation-duration) ease;
182217}
183218
@@ -185,16 +220,54 @@ body.reduced-motion #bg_custom {
185220 background-color: rgba(255, 255, 255, 0.2);
186221}
187222
188-.bg_example .jg-unlock {
223+/* Scroll-to-Top Button */
189- display: none;
224+#bg-scroll-top {
225+ position: absolute;
226+ bottom: 20px;
227+ right: 20px;
228+ width: 42px;
229+ height: 42px;
230+ background: var(--SmartThemeBlurTintColor);
231+ color: var(--SmartThemeBodyColor);
232+ border: 1px solid var(--SmartThemeBorderColor);
233+ border-radius: 50%;
234+ cursor: pointer;
235+ z-index: 10;
236+ font-size: 18px;
237+ display: inline-flex;
238+ align-items: center;
239+ justify-content: center;
240+ opacity: 0;
241+ transition: opacity var(--animation-duration) ease;
242+ pointer-events: none;
190243}
191244
192-.bg_example.locked .jg-lock {
245+#bg-scroll-top.visible {
193246 displayopacity: none1;
247+ pointer-events: auto;
194248}
195249
196-.bg_example.locked .jg-unlock {
250+#bg-scroll-top:hover {
197251 displayfilter: flexbrightness(150%);
252+ outline: 1px solid var(--interactable-outline-color);
253+}
254+
255+#bg-scroll-top .fa-solid {
256+ margin: 0;
257+ padding: 0;
258+ line-height: 1;
259+}
260+
261+.thumbnail-clipper {
262+ position: absolute;
263+ top: -2px;
264+ left: -2px;
265+ right: -2px;
266+ bottom: -2px;
267+ overflow: hidden;
268+ border-radius: inherit;
269+ background-size: cover;
270+ background-position: center;
198271}
199272
200273.bg_example:not([custom="true"]) .jg-copy,
public/css/mobile-styles.css+31 -44
@@ -10,6 +10,18 @@
1010 flex-basis: 100%;
1111 }
1212
13+ #rm_button_panel_pin_div,
14+ #lm_button_panel_pin_div {
15+ display: none;
16+ }
17+
18+ #rm_button_characters {
19+ font-size: var(--topBarIconSize);
20+ }
21+
22+ #CharListButtonAndHotSwaps {
23+ align-items: center;
24+ }
1325
1426 #send_form.compact #leftSendForm,
1527 #send_form.compact #rightSendForm {
@@ -21,6 +33,15 @@
2133 display: none;
2234 }
2335
36+ #bg_menu_content,
37+ #bg_custom_content {
38+ grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
39+ }
40+
41+ .bg_list {
42+ width: unset;
43+ }
44+
2445 .bg_button {
2546 font-size: 15px;
2647 }
@@ -40,6 +61,11 @@
4061 z-index: 4;
4162 }
4263
64+ .bg_example.mobile-menu-open .mobile-only-menu-toggle {
65+ opacity: 0;
66+ pointer-events: none;
67+ }
68+
4369 .bg_example .mobile-only-menu-toggle {
4470 display: flex;
4571 align-items: center;
@@ -57,48 +83,13 @@
5783 backdrop-filter: blur(2px);
5884 }
5985
60- #bg-header-controls {
86+ .bg_example .jg-button {
6187 flex-wrapwidth: wrap30px;
6288 row-gapheight: 10px30px;
63- }
64-
65- #bg-header-fixed>.flex-container {
66- flex-wrap: wrap;
67- row-gap: 0px;
68- }
69-
70- #Backgrounds:not(.selection-mode-active) #bg-header-fixed>.flex-container::after {
71- content: '';
72- order: 1;
73- flex-basis: 100%;
74- height: 0;
75- }
76-
77- /* --- Row 1 Item --- */
78- #bg-header-fixed #bg-header-title {
79- order: 1;
80- flex-grow: 1;
81- }
82-
83- #bg-header-fixed #background_fitting,
84- #bg-header-fixed #auto_background {
85- order: 1;
86- }
87-
88- /* --- Row 2 Item --- */
89- #bg-header-fixed #bg-filter {
90- order: 2;
91- flex-grow: 1;
92- min-width: 0;
9389 }
9490
95- /* --- Row 3 Item --- */
91+ #background_fitting {
96- #bg-header-fixed #add_background_button_top {
92+ max-width: 6em;
97- order: 3;
98- width: 100%;
99- text-align: center;
100- padding-top: 0.5em;
101- padding-bottom: 0.5em;
10293 }
10394
10495 #Backgrounds.drawer-content.openDrawer.bg-drawer-layout {
@@ -429,10 +420,6 @@
429420 .horde_multiple_hint {
430421 display: none;
431422 }
432-
433- .bg_list {
434- width: unset;
435- }
436423}
437424
438425/*landscape mode phones and ipads*/
public/css/world-info.css+4 -0
@@ -301,6 +301,10 @@ select.keyselect+span.select2-container .select2-selection--multiple {
301301 display: none;
302302}
303303
304+.world_entry:not(:has(select[name="position"] option[value="7"]:checked)) .world_entry_form_control:has(input[name="outletName"]) {
305+ display: none;
306+}
307+
304308.world_entry label[for="__invisible"] {
305309 visibility: hidden;
306310 pointer-events: none;
public/img/apple-icon-192x192.png+0 -0

Binary file

public/img/apple-icon-512x512.png+0 -0

Binary file

public/index.html+149 -180
@@ -49,7 +49,6 @@
4949
5050<body class="no-blur">
5151 <div id="preloader"></div>
52- <div id="bg_custom"></div>
5352 <div id="bg1"></div>
5453 <div id="character_context_menu" class="hidden">
5554 <ul>
@@ -254,7 +253,7 @@
254253 </div>
255254 <div id="common-gen-settings-block" class="width100p">
256255 <div id="pro-settings-block" class="flex-container gap10h5v justifyCenter">
257256 <div id="amount_gen_block" class="range-block-range-and-counter alignitemscenter flex-container marginBot5 flexFlowColumn flexBasis48p flexGrow flexShrink gap0">
258257 <small data-i18n="response legth(tokens)">Response (tokens)</small>
259258 <input class="neo-range-slider" type="range" id="amount_gen" name="volume" min="16" max="2048" step="1">
260259 <div data-randomization-disabled="true" class="wide100p">
@@ -285,7 +284,7 @@
285284 </label>
286285 </div>
287286 </div>
288287 <div id="max_context_block" class="range-block-range-and-counter alignitemscenter flex-container marginBot5 flexFlowColumn flexBasis48p flexGrow flexShrink gap0">
289288 <small data-i18n="context size(tokens)">Context (tokens)</small>
290289 <input class="neo-range-slider" type="range" id="max_context" name="volume" min="512" max="8192" step="64">
291290 <div data-randomization-disabled="true" class="wide100p">
@@ -649,7 +648,7 @@
649648 Max Response Length (tokens)
650649 </div>
651650 <div class="wide100p">
652651 <input type="number" id="openai_max_tokens" name="openai_max_tokens" class="text_pole" min="1" max="65536" step="1">
653652 </div>
654653 </div>
655654 <div class="range-block" data-source="openai,custom,xai,aimlapi,moonshot,azure_openai">
@@ -676,6 +675,9 @@
676675 <div data-source="openrouter">
677676 <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="openrouter_max_prompt_cost" data-i18n="Unknown">Unknown</span>
678677 </div>
678+ <div data-source="electronhub">
679+ <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="electronhub_max_prompt_cost" data-i18n="Unknown">Unknown</span>
680+ </div>
679681 <hr>
680682 <div class="range-block">
681683 <label for="stream_toggle" title="Enable OpenAI completion streaming" data-i18n="[title]Enable OpenAI completion streaming" class="checkbox_label widthFreeExpand">
@@ -1463,12 +1465,10 @@
14631465 </div>
14641466 </div>
14651467 <div class="range-block marginTop5" title="Tokens across which sequence matching is not continued. Specified as a comma-separated list of quoted strings." data-i18n="[title]DRY_Sequence_Breakers_desc">
14661468 <div class="range-block-title textAlignCenter flex-container justifyCenter alignitemscenter">
14671469 <small data-i18n="Sequence Breakers">Sequence Breakers</small>
14681470 </div>
1469- <div class="wide100p">
1471+ <textarea id="dry_sequence_breakers_textgenerationwebui" class="text_pole textarea_compact" name="sequence_breakers" rows="3" data-i18n="[placeholder]JSON-serialized array of strings." placeholder="JSON-serialized array of strings."></textarea>
1470- <textarea id="dry_sequence_breakers_textgenerationwebui" class="text_pole textarea_compact" name="sequence_breakers" rows="3" data-i18n="[placeholder]JSON-serialized array of strings." placeholder="JSON-serialized array of strings."></textarea>
1471- </div>
14721472 </div>
14731473 </div>
14741474 <div data-tg-type="ooba, mancer, koboldcpp, tabby, llamacpp, aphrodite" id="dynatemp_block_ooba" class="wide100p">
@@ -1502,7 +1502,7 @@
15021502 <div data-tg-type="ooba,infermaticai,koboldcpp,llamacpp,tabby" id="mirostat_block_ooba" class="wide100p">
15031503 <h4 class="wide100p textAlignCenter">
15041504 <label data-i18n="Mirostat (mode=1 is only for llama.cpp)">Mirostat</label>
15051505 <div class=" fa-solid fa-circle-info opacity50p " data-i18n="[title]Mirostat_desc" title="Mirostat is a thermostat for output perplexity.&#13;Mirostat matches the output perplexity to that of the input, thus avoiding the repetition trap&#13;(where, as the autoregressive inference produces text, the perplexity of the output tends toward zero)&#13;and the confusion trap (where the perplexity diverges).&#13;For details, see the paper Mirostat: A Neural Text Decoding Algorithm that Directly Controls Perplexity by Basu et al. (2020).&#13;Mode chooses the Mirostat version. 0=disable, 1=Mirostat 1.0 (llama.cpp only), 2=Mirostat 2.0.&#13;Tau = Variability parameter for Mirostat outputs.&#13;Eta = Learning rate of Mirostat."></div>
15061506 </h4>
15071507 <div class="flex-container flexFlowRow gap10px flexShrink">
15081508 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
@@ -1511,18 +1511,12 @@
15111511 <input class="neo-range-input" type="number" min="0" max="2" step="1" data-for="mirostat_mode_textgenerationwebui" id="mirostat_mode_counter_textgenerationwebui">
15121512 </div>
15131513 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
1514- <label>
1514+ <small data-i18n="Mirostat Tau">Tau</small>
1515- <small data-i18n="Mirostat Tau">Tau</small>
1516- <div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]Variability parameter for Mirostat outputs" title="Variability parameter for Mirostat outputs."></div>
1517- </label>
15181515 <input class="neo-range-slider" type="range" id="mirostat_tau_textgenerationwebui" name="volume" min="0" max="20" step="0.01" />
15191516 <input class="neo-range-input" type="number" min="0" max="20" step="0.01" data-for="mirostat_tau_textgenerationwebui" id="mirostat_tau_counter_textgenerationwebui">
15201517 </div>
15211518 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
1522- <label>
1519+ <small data-i18n="Mirostat Eta">Eta</small>
1523- <small data-i18n="Mirostat Eta">Eta</small>
1524- <div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]Learning rate of Mirostat" title="Learning rate of Mirostat."></div>
1525- </label>
15261520 <input class="neo-range-slider" type="range" id="mirostat_eta_textgenerationwebui" name="volume" min="0" max="1" step="0.01" />
15271521 <input class="neo-range-input" type="number" min="0" max="1" step="0.01" data-for="mirostat_eta_textgenerationwebui" id="mirostat_eta_counter_textgenerationwebui">
15281522 </div>
@@ -1583,17 +1577,13 @@
15831577 </label>
15841578 <label data-tg-type="ooba, tabby" class="checkbox_label flexGrow flexShrink" for="add_bos_token_textgenerationwebui">
15851579 <input type="checkbox" id="add_bos_token_textgenerationwebui" />
1586- <label>
1580+ <small data-i18n="Add BOS Token">Add BOS Token</small>
1587- <small data-i18n="Add BOS Token">Add BOS Token</small>
1581+ <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Add the bos_token to the beginning of prompts. Disabling this can make the replies more creative" title="Add the bos_token to the beginning of prompts. Disabling this can make the replies more creative."></div>
1588- <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Add the bos_token to the beginning of prompts. Disabling this can make the replies more creative" title="Add the bos_token to the beginning of prompts. Disabling this can make the replies more creative."></div>
1589- </label>
15901582 </label>
15911583 <label data-tg-type="ooba, llamacpp, tabby, koboldcpp, dreamgen" class="checkbox_label flexGrow flexShrink" for="ban_eos_token_textgenerationwebui">
15921584 <input type="checkbox" id="ban_eos_token_textgenerationwebui" />
1593- <label>
1585+ <small data-i18n="Ban EOS Token">Ban EOS Token</small>
1594- <small data-i18n="Ban EOS Token">Ban EOS Token</small>
1586+ <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Ban the eos_token. This forces the model to never end the generation prematurely" title="Ban the eos_token. This forces the model to never end the generation prematurely."></div>
1595- <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Ban the eos_token. This forces the model to never end the generation prematurely" title="Ban the eos_token. This forces the model to never end the generation prematurely."></div>
1596- </label>
15971587 </label>
15981588 <label data-tg-type="vllm, aphrodite, infermaticai" class="checkbox_label" for="ignore_eos_token_textgenerationwebui">
15991589 <input type="checkbox" id="ignore_eos_token_textgenerationwebui" />
@@ -1610,19 +1600,14 @@
16101600 </label>
16111601 <label data-tg-type="ooba, aphrodite, tabby" class="checkbox_label flexGrow flexShrink" for="temperature_last_textgenerationwebui">
16121602 <input type="checkbox" id="temperature_last_textgenerationwebui" />
1613- <label>
1603+ <small data-i18n="Temperature Last">Temperature Last</small>
1614- <small data-i18n="Temperature Last">Temperature Last</small>
1604+ <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Temperature_Last_desc" title="Use the temperature sampler last. This is almost always the sensible thing to do.&#13;When enabled: sample the set of plausible tokens first, then apply temperature to adjust their relative probabilities (technically, logits).&#13;When disabled: apply temperature to adjust the relative probabilities of ALL tokens first, then sample plausible tokens from that.&#13;Disabling Temperature Last boosts the probabilities in the tail of the distribution, which tends to amplify the chances of getting an incoherent response."></div>
1615- <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Temperature_Last_desc" title="Use the temperature sampler last. This is almost always the sensible thing to do.&#13;When enabled: sample the set of plausible tokens first, then apply temperature to adjust their relative probabilities (technically, logits).&#13;When disabled: apply temperature to adjust the relative probabilities of ALL tokens first, then sample plausible tokens from that.&#13;Disabling Temperature Last boosts the probabilities in the tail of the distribution, which tends to amplify the chances of getting an incoherent response."></div>
1616- </label>
16171605 </label>
16181606 <label data-tg-type="tabby" class="checkbox_label flexGrow flexShrink" for="speculative_ngram_textgenerationwebui">
16191607 <input type="checkbox" id="speculative_ngram_textgenerationwebui" />
1620- <label>
1608+ <small data-i18n="Speculative Ngram">Speculative Ngram</small>
1621- <small data-i18n="Speculative Ngram">Speculative Ngram</small>
1609+ <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Use a different speculative decoding method without a draft model" title="Use a different speculative decoding method without a draft model.&#13;Using a draft model is preferred. Speculative ngram is not as effective."></div>
1622- <div class="fa-solid fa-circle-info opacity50p " data-i18n="[title]Use a different speculative decoding method without a draft model" title="Use a different speculative decoding method without a draft model.&#13;Using a draft model is preferred. Speculative ngram is not as effective."></div>
1623- </label>
16241610 </label>
1625-
16261611 <label data-tg-type="vllm, aphrodite, infermaticai" class="checkbox_label" for="spaces_between_special_tokens_textgenerationwebui">
16271612 <input type="checkbox" id="spaces_between_special_tokens_textgenerationwebui" />
16281613 <small data-i18n="Spaces Between Special Tokens">Spaces Between Special Tokens</small>
@@ -1663,9 +1648,11 @@
16631648 </div>
16641649 </div>
16651650 </div>
16661651 <div class="range-block wide100p" id="logit_bias_block_ooba">
16671652 <div id="logit_bias_textgenerationwebui" class="range-block-title title_restorable">
1668- <strong data-i18n="Logit Bias">Logit Bias</strong>
1653+ <h4>
1654+ <span data-i18n="Logit Bias">Logit Bias</span>
1655+ </h4>
16691656 <div id="textgen_logit_bias_new_entry" class="menu_button menu_button_icon">
16701657 <i class="fa-xs fa-solid fa-plus"></i>
16711658 <small data-i18n="Add">Add</small>
@@ -1678,7 +1665,7 @@
16781665 <div class="logit_bias_list"></div>
16791666 </div>
16801667 </div>
16811668 <div id="cfg_block_ooba" data-tg-type="ooba, tabby" class="flex-container flexFlowColumn wide100p">
16821669 <hr class="width100p">
16831670 <h4 class="textAlignCenter">
16841671 <span data-i18n="CFG">CFG</span>
@@ -1690,29 +1677,23 @@
16901677 <input class="neo-range-input" type="number" min="0.1" max="4" step="0.05" data-for="guidance_scale_textgenerationwebui" id="guidance_scale_counter_textgenerationwebui">
16911678 </div>
16921679 <div class="range-block">
16931680 <div class="range-blockalignitemscenter justifyCenter flex-titlecontainer justifyLeftflexShrink">
16941681 <spansmall data-i18n="Negative Prompt">Negative Prompt</spansmall>
1695- <small>
1682+ <div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]Used if CFG Scale is unset globally, per chat or character" title="Used if CFG Scale is unset globally, per chat or character"></div>
1696- <div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]Used if CFG Scale is unset globally, per chat or character" title="Used if CFG Scale is unset globally, per chat or character"></div>
1697- </small>
1698- </div>
1699- <div class="wide100p">
1700- <textarea id="negative_prompt_textgenerationwebui" class="text_pole textarea_compact" name="negative_prompt" rows="3" data-i18n="[placeholder]Add text here that would make the AI generate things you don't want in your outputs." placeholder="Add text here that would make the AI generate things you don't want in your outputs."></textarea>
17011683 </div>
1684+ <textarea id="negative_prompt_textgenerationwebui" class="wide100p text_pole textarea_compact" name="negative_prompt" rows="3" data-i18n="[placeholder]Add text here that would make the AI generate things you don't want in your outputs." placeholder="Add text here that would make the AI generate things you don't want in your outputs."></textarea>
17021685 </div>
17031686 </div>
17041687 <div id="grammar_block_ooba" data-tg-type="ooba,aphrodite,tabby" class="wide100p">
17051688 <hr class="wide100p">
17061689 <h4 class="wide100p textAlignCenter">
1707- <label>
1690+ <div class="flex-container justifyCenter alignitemscenter">
17081691 <span data-i18n="Grammar String">Grammar String</span>
17091692 <div class="margin5 fa-solid fa-circle-info opacity50p " data-i18n="[title]GBNF or EBNF, depends on the backend in use. If you're using this you should know which." title="GBNF or EBNF, depends on the backend in use. If you're using this you should know which."></div>
17101693 <a href="https://github.com/ggerganov/llama.cpp/blob/master/grammars/README.md" target="_blank">
1711- <small>
1694+ <div class="fa-solid fa-up-right-from-square note-link-span"></div>
1712- <div class="fa-solid fa-up-right-from-square note-link-span"></div>
1713- </small>
17141695 </a>
17151696 </labeldiv>
17161697 </h4>
17171698 <textarea id="grammar_string_textgenerationwebui" rows="4" class="text_pole textarea_compact monospace" data-i18n="[placeholder]Type in the desired custom grammar" placeholder="Type in the desired custom grammar"></textarea>
17181699 </div>
@@ -2077,7 +2058,7 @@
20772058 </span>
20782059 </div>
20792060 </div>
20802061 <div class="range-block" data-source="deepseek,aimlapi,openrouter,custom,claude,xai,makersuite,vertexai,pollinations,moonshot,mistralai,fireworks,cometapi,electronhub,azure_openai,nanogpt">
20812062 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
20822063 <input id="openai_show_thoughts" type="checkbox" />
20832064 <span data-i18n="Request model reasoning">Request model reasoning</span>
@@ -2144,7 +2125,7 @@
21442125 </div>
21452126 </div>
21462127 </div>
21472128 <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom,electronhub,azure_openai">
21482129 <div id="logit_bias_openai" class="range-block-title openai_restorable" data-i18n="Logit Bias">
21492130 Logit Bias
21502131 </div>
@@ -3035,6 +3016,10 @@
30353016 <h4 data-i18n="Claude Model">Claude Model</h4>
30363017 <select id="model_claude_select">
30373018 <optgroup label="Versions">
3019+ <option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
3020+ <option value="claude-sonnet-4-5-20250929">claude-sonnet-4-5-20250929</option>
3021+ <option value="claude-haiku-4-5">claude-haiku-4-5</option>
3022+ <option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
30383023 <option value="claude-opus-4-1">claude-opus-4-1</option>
30393024 <option value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>
30403025 <option value="claude-opus-4-0">claude-opus-4-0</option>
@@ -3082,7 +3067,7 @@
30823067 <div class="marginTopBot5">
30833068 <div class="inline-drawer wide100p">
30843069 <div class="inline-drawer-toggle inline-drawer-header">
30853070 <b data-i18n="OpenRouter Model OrderSorting">OpenRouter Model Sorting</b>
30863071 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
30873072 </div>
30883073 <div class="inline-drawer-content m-b-1">
@@ -3174,46 +3159,32 @@
31743159 <option value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
31753160 <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
31763161 <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
3177- <option value="gemini-2.5-pro-exp-03-25">gemini-2.5-pro-exp-03-25</option>
31783162 <option value="gemini-2.5-flash">gemini-2.5-flash</option>
3163+ <option value="gemini-2.5-flash-preview-09-2025">gemini-2.5-flash-preview-09-2025</option>
31793164 <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
3180- <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
31813165 <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
3166+ <option value="gemini-2.5-flash-lite-preview-09-2025">gemini-2.5-flash-lite-preview-09-2025</option>
31823167 <option value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
3168+ <option value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
31833169 <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
31843170 </optgroup>
31853171 <optgroup label="Gemini 2.0">
31863172 <option value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-pro-exp-03-25</option>
31873173 <option value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-pro-exp-03-25</option>
31883174 <option value="gemini-exp-1206">gemini-exp-1206 → 2.5-pro-exp-03-25</option>
31893175 <option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
31903176 <option value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>
31913177 <option value="gemini-2.0-flash-preview-image-generation">gemini-2.0-flash-preview-image-generation</option>
31923178 <option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
31933179 <option value="gemini-2.0-flash">gemini-2.0-flash</option>
31943180 <option value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21 → 2.5-flash-preview-0405-1720</option>
31953181 <option value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-0405-1720</option>
31963182 <option value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-0405-1720</option>
31973183 <option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
31983184 <option value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>
31993185 <option value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>
32003186 <option value="gemini-2.0-flash-lite">gemini-2.0-flash-lite</option>
32013187 </optgroup>
3202- <optgroup label="Gemini 1.5">
3203- <option value="gemini-1.5-pro-latest">gemini-1.5-pro-latest</option>
3204- <option value="gemini-1.5-pro-002">gemini-1.5-pro-002</option>
3205- <option value="gemini-1.5-pro-001">gemini-1.5-pro-001</option>
3206- <option value="gemini-1.5-pro">gemini-1.5-pro</option>
3207- <option value="gemini-1.5-flash-latest">gemini-1.5-flash-latest</option>
3208- <option value="gemini-1.5-flash-002">gemini-1.5-flash-002</option>
3209- <option value="gemini-1.5-flash-001">gemini-1.5-flash-001</option>
3210- <option value="gemini-1.5-flash">gemini-1.5-flash</option>
3211- <option value="gemini-1.5-flash-8b-latest">gemini-1.5-flash-8b-latest</option>
3212- <option value="gemini-1.5-flash-8b-001">gemini-1.5-flash-8b-001</option>
3213- <option value="gemini-1.5-flash-8b-exp-0924">gemini-1.5-flash-8b-exp-0924</option>
3214- <option value="gemini-1.5-flash-8b-exp-0827">gemini-1.5-flash-8b-exp-0827</option>
3215- <option value="gemini-1.5-flash-8b">gemini-1.5-flash-8b</option>
3216- </optgroup>
32173188 <optgroup label="Gemma">
32183189 <option value="gemma-3n-e4b-it">gemma-3n-e4b-it</option>
32193190 <option value="gemma-3n-e2b-it">gemma-3n-e2b-it</option>
@@ -3225,6 +3196,9 @@
32253196 <optgroup label="LearnLM">
32263197 <option value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>
32273198 </optgroup>
3199+ <optgroup label="Robotics-ER">
3200+ <option value="gemini-robotics-er-1.5-preview">gemini-robotics-er-1.5-preview</option>
3201+ </optgroup>
32283202 <optgroup id="google_other_models" label="Other"></optgroup>
32293203 </select>
32303204 </div>
@@ -3350,15 +3324,9 @@
33503324 <!-- data-mode="full" is for models that require a service account -->
33513325 <optgroup label="Gemini 2.5">
33523326 <option value="gemini-2.5-pro">gemini-2.5-pro</option>
3353- <option value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
3354- <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
3355- <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
3356- <option value="gemini-2.5-pro-exp-03-25" data-mode="full">gemini-2.5-pro-exp-03-25</option>
33573327 <option value="gemini-2.5-flash">gemini-2.5-flash</option>
3358- <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
3359- <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
33603328 <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
33613329 <option value="gemini-2.5-flash-lite-preview-06-17image">gemini-2.5-flash-lite-preview-06-17image</option>
33623330 <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
33633331 </optgroup>
33643332 <optgroup label="Gemini 2.0">
@@ -3383,67 +3351,7 @@
33833351 <div>
33843352 <h4 data-i18n="MistralAI Model">MistralAI Model</h4>
33853353 <select id="model_mistralai_select">
3386- <optgroup label="Latest">
3354+ <option data-i18n="-- Connect to the API --" value="">-- Connect to the API --</option>
3387- <option value="open-mistral-nemo">open-mistral-nemo</option>
3388- <option value="open-mistral-7b">open-mistral-7b</option>
3389- <option value="open-mixtral-8x7b">open-mixtral-8x7b</option>
3390- <option value="open-mixtral-8x22b">open-mixtral-8x22b</option>
3391- <option value="open-codestral-mamba">open-codestral-mamba</option>
3392- <option value="ministral-3b-latest">ministral-3b-latest</option>
3393- <option value="ministral-8b-latest">ministral-8b-latest</option>
3394- <option value="mistral-tiny-latest">mistral-tiny-latest</option>
3395- <option value="mistral-small-latest">mistral-small-latest</option>
3396- <option value="mistral-medium-latest">mistral-medium-latest</option>
3397- <option value="mistral-large-latest">mistral-large-latest</option>
3398- <option value="mistral-saba-latest">mistral-saba-latest</option>
3399- <option value="codestral-latest">codestral-latest</option>
3400- <option value="codestral-mamba-latest">codestral-mamba-latest</option>
3401- <option value="pixtral-12b-latest">pixtral-12b-latest</option>
3402- <option value="pixtral-large-latest">pixtral-large-latest</option>
3403- <option value="devstral-small-latest">devstral-small-latest</option>
3404- <option value="devstral-medium-latest">devstral-medium-latest</option>
3405- <option value="magistral-small-latest">magistral-small-latest</option>
3406- <option value="magistral-medium-latest">magistral-medium-latest</option>
3407- </optgroup>
3408- <optgroup label="Sub-versions">
3409- <option value="open-mistral-nemo-2407">open-mistral-nemo-2407</option>
3410- <option value="open-mixtral-8x22b-2404">open-mixtral-8x22b-2404</option>
3411- <option value="ministral-3b-2410">ministral-3b-2410</option>
3412- <option value="ministral-8b-2410">ministral-8b-2410</option>
3413- <option value="mistral-tiny-2312">mistral-tiny-2312</option>
3414- <option value="mistral-tiny-2407">mistral-tiny-2407</option>
3415- <option value="mistral-small-2312">mistral-small-2312</option>
3416- <option value="mistral-small-2402">mistral-small-2402</option>
3417- <option value="mistral-small-2409">mistral-small-2409</option>
3418- <option value="mistral-small-2501">mistral-small-2501</option>
3419- <option value="mistral-small-2503">mistral-small-2503</option>
3420- <option value="mistral-small-2506">mistral-small-2506</option>
3421- <option value="mistral-medium-2312">mistral-medium-2312</option>
3422- <option value="mistral-medium-2505">mistral-medium-2505</option>
3423- <option value="mistral-medium-2508">mistral-medium-2508</option>
3424- <option value="mistral-large-2402">mistral-large-2402</option>
3425- <option value="mistral-large-2407">mistral-large-2407</option>
3426- <option value="mistral-large-2411">mistral-large-2411</option>
3427- <option value="mistral-large-pixtral-2411">mistral-large-pixtral-2411</option>
3428- <option value="mistral-saba-2502">mistral-saba-2502</option>
3429- <option value="codestral-2405">codestral-2405</option>
3430- <option value="codestral-2405-blue">codestral-2405-blue</option>
3431- <option value="codestral-mamba-2407">codestral-mamba-2407</option>
3432- <option value="codestral-2411-rc5">codestral-2411-rc5</option>
3433- <option value="codestral-2412">codestral-2412</option>
3434- <option value="codestral-2501">codestral-2501</option>
3435- <option value="codestral-2508">codestral-2508</option>
3436- <option value="pixtral-12b-2409">pixtral-12b-2409</option>
3437- <option value="pixtral-large-2411">pixtral-large-2411</option>
3438- <option value="devstral-small-2505">devstral-small-2505</option>
3439- <option value="devstral-small-2507">devstral-small-2507</option>
3440- <option value="devstral-medium-2507">devstral-medium-2507</option>
3441- <option value="magistral-small-2506">magistral-small-2506</option>
3442- <option value="magistral-medium-2506">magistral-medium-2506</option>
3443- <option value="magistral-small-2507">magistral-small-2507</option>
3444- <option value="magistral-medium-2507">magistral-medium-2507</option>
3445- </optgroup>
3446- <optgroup id="mistralai_other_models" label="Other"></optgroup>
34473355 </select>
34483356 </div>
34493357 </form>
@@ -3473,6 +3381,9 @@
34733381 </div>
34743382 <div id="electronhub_form" data-source="electronhub">
34753383 <h4 data-i18n="Electron Hub API Key">Electron Hub API Key</h4>
3384+ <div>
3385+ <a href="https://playground.electronhub.ai/console" target="_blank" data-i18n="View Remaining Credits">View Remaining Credits</a>
3386+ </div>
34763387 <div class="flex-container">
34773388 <input id="api_key_electronhub" name="api_key_electronhub" class="text_pole flex1" value="" type="text" autocomplete="off">
34783389 <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_electronhub"></div>
@@ -3480,10 +3391,43 @@
34803391 <div data-for="api_key_electronhub" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
34813392 For privacy reasons, your API key will be hidden after you click 'Connect'.
34823393 </div>
3483- <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>
3394+ <div>
3484- <select id="model_electronhub_select">
3395+ <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>
3485- <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
3396+ <select id="model_electronhub_select">
3486- </select>
3397+ <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
3398+ </select>
3399+ </div>
3400+ <div class="marginTopBot5">
3401+ <div class="inline-drawer wide100p">
3402+ <div class="inline-drawer-toggle inline-drawer-header">
3403+ <b data-i18n="Electron Hub Model Sorting">Electron Hub Model Sorting</b>
3404+ <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
3405+ </div>
3406+ <div class="inline-drawer-content m-b-1">
3407+ <div class="marginTopBot5">
3408+ <label for="electronhub_sort_models" class="checkbox_label">
3409+ <select id="electronhub_sort_models">
3410+ <option data-i18n="Alphabetically" value="alphabetically">Alphabetically</option>
3411+ <option data-i18n="Input Price" value="pricing.input">Input Price (cheapest)</option>
3412+ <option data-i18n="Output Price" value="pricing.output">Output Price (cheapest)</option>
3413+ <option data-i18n="Context Size" value="context_length">Context Size</option>
3414+ </select>
3415+ </label>
3416+ </div>
3417+ <div class="marginTopBot5">
3418+ <label for="electronhub_group_models" class="checkbox_label">
3419+ <input id="electronhub_group_models" type="checkbox" />
3420+ <span data-i18n="Group by vendors">Group by vendors</span>
3421+ </label>
3422+ <div class="toggle-description justifyLeft wide100p">
3423+ <span data-i18n="Group by vendors Description">
3424+ Put OpenAI models in one group, Anthropic models in other group, etc. Can be combined with sorting.
3425+ </span>
3426+ </div>
3427+ </div>
3428+ </div>
3429+ </div>
3430+ </div>
34873431 </div>
34883432 <div id="nanogpt_form" data-source="nanogpt">
34893433 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
@@ -3666,18 +3610,12 @@
36663610 <div data-for="api_key_xai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
36673611 For privacy reasons, your API key will be hidden after you click 'Connect'.
36683612 </div>
3669- <h4 data-i18n="xAI Model">xAI Model</h4>
3613+ <div>
3670- <select id="model_xai_select">
3614+ <h4 data-i18n="xAI Model">xAI Model</h4>
3671- <option value="grok-4-0709">grok-4-0709</option>
3615+ <select id="model_xai_select">
3672- <option value="grok-3-beta">grok-3-beta</option>
3616+ <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
3673- <option value="grok-3-fast-beta">grok-3-fast-beta</option>
3617+ </select>
3674- <option value="grok-3-mini-beta">grok-3-mini-beta</option>
3618+ </div>
3675- <option value="grok-3-mini-fast-beta">grok-3-mini-fast-beta</option>
3676- <option value="grok-2-vision-1212">grok-2-vision-1212</option>
3677- <option value="grok-2-1212">grok-2-1212</option>
3678- <option value="grok-vision-beta">grok-vision-beta</option>
3679- <option value="grok-beta">grok-beta</option>
3680- </select>
36813619 </div>
36823620 <div id="aimlapi_form" data-source="aimlapi">
36833621 <h4>
@@ -4279,7 +4217,7 @@
42794217 Token Padding
42804218 </small>
42814219 </div>
42824220 <input id="token_padding" class="text_pole textarea_compact" type="number" min="-2048" max="2048" step="1" />
42834221 </div>
42844222 </div>
42854223 <div>
@@ -4959,7 +4897,6 @@
49594897 <small data-i18n="Smooth Streaming">
49604898 Smooth Streaming
49614899 </small>
4962- <i class="fa-solid fa-flask" data-i18n="[title]Experimental feature. May not work for all backends." title="Experimental feature. May not work for all backends."></i>
49634900 </div>
49644901 <div id="smooth_streaming_speed_control" class="flexBasis100p wide100p">
49654902 <input type="range" id="smooth_streaming_speed" name="smooth_streaming_speed" min="0" max="100" step="10" value="50">
@@ -4970,6 +4907,11 @@
49704907 </div>
49714908 </div>
49724909 </label>
4910+ <label class="checkbox_label" for="stream_fade_in" title="Fade in streamed text when it appears, instead of it just popping in." data-i18n="[title]Fade in streamed text when it appears, instead of it just popping in">
4911+ <input id="stream_fade_in" type="checkbox" />
4912+ <small data-i18n="Stream Fade-In">Stream Fade-In</small>
4913+ <i class="fa-solid fa-flask" data-i18n="[title]Experimental feature. May not work for all backends." title="Experimental feature. May not work for all backends."></i>
4914+ </label>
49734915
49744916 <label for="play_message_sound" class="checkbox_label" title="Play a sound when a message generation finishes." data-i18n="[title]Play a sound when a message generation finishes">
49754917 <input id="play_message_sound" type="checkbox" />
@@ -5339,15 +5281,18 @@
53395281 </div>
53405282 </div>
53415283 </div>
53425284 <div id="logo_blockbackgrounds-button" class="drawer">
53435285 <div id="site_logobackgrounds-drawer-toggle" class="drawer-toggle drawer-header" title="Change Background Image" data-i18n="[title]Change Background Image">
53445286 <div class="drawer-icon fa-solid fa-panorama fa-fw closedIcon"></div>
53455287 </div>
53465288 <div id="Backgrounds" class="drawer-content closedDrawer bg-drawer-layout">
53475289 <div id="bg-header-fixed">
53485290 <div class="flexbg-container alignItemsBaseline wide100pheader-row-1">
5349- <h3 id="bg-header-title" class="margin0" data-i18n="Backgrounds">Backgrounds</h3>
5291+ <label for="add_bg_button" id="add_background_button_top" class="menu_button menu_button_icon" data-i18n="[title]Add a new background" title="Add a new background">
5350- <input id="bg-filter" class="text_pole flex1" type="search" data-i18n="[placeholder]Search" placeholder="Search" />
5292+ <i class="fa-solid fa-plus"></i>
5293+ <span data-i18n="Add Background">Add Background</span>
5294+ </label>
5295+ <span class="expander"></span>
53515296 <select id="background_fitting" class="text_pole" data-i18n="[title]Background Fitting" title="Background Fitting">
53525297 <option value="classic" data-i18n="Classic">Classic</option>
53535298 <option value="cover" data-i18n="Cover">Cover</option>
@@ -5359,16 +5304,25 @@
53595304 <i class="fa-solid fa-wand-magic"></i>
53605305 <span data-i18n="Auto-select">Auto-select</span>
53615306 </div>
5362- <label for="add_bg_button" id="add_background_button_top" class="menu_button menu_button_icon interactable" title="Add a new background">
5307+ </div>
53635308 <idiv class="fabg-solid faheader-plusrow-2"></i>
5364- <span data-i18n="Add Background">Add Background</span>
5309+ <input id="bg-filter" class="text_pole" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
5365- </label>
53665310 </div>
53675311 </div>
53685312 <div id="bg-scrollable-content">
5369- <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter">
5313+ <div class="heading-container-with-controls">
5370- System Backgrounds
5314+ <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter heading-text">
5371- </h3>
5315+ System Backgrounds
5316+ </h3>
5317+ <div class="heading-controls">
5318+ <button id="bg_thumb_zoom_out" class="menu_button menu_button_icon" title="Make thumbnails smaller" data-i18n="[title]Make thumbnails smaller">
5319+ <i class="fa-solid fa-minus"></i>
5320+ </button>
5321+ <button id="bg_thumb_zoom_in" class="menu_button menu_button_icon" title="Make thumbnails larger" data-i18n="[title]Make thumbnails larger">
5322+ <i class="fa-solid fa-plus"></i>
5323+ </button>
5324+ </div>
5325+ </div>
53725326 <div id="bg_menu_content" class="bg_list">
53735327 </div>
53745328 <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">
@@ -5381,8 +5335,11 @@
53815335 </div>
53825336 </div>
53835337 <form id="form_bg_upload" style="display: none;">
53845338 <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*">
53855339 </form>
5340+ <button id="bg-scroll-top" type="button" class="menu_button menu_button_icon" title="Scroll backgrounds to top" data-i18n="[title]Scroll backgrounds to top">
5341+ <i class="fa-solid fa-chevron-up" aria-hidden="true"></i>
5342+ </button>
53865343 </div>
53875344 </div>
53885345 <div id="extensions-settings-button" class="drawer">
@@ -5540,7 +5497,10 @@
55405497 </div>
55415498 </div>
55425499
55435500 <h4 data-i18nclass="Personaflex-container DescriptionalignItemsBaseline">Persona Description</h4>
5501+ <span data-i18n="Persona Description">Persona Description</span>
5502+ <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="persona_description" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
5503+ </h4>
55445504 <textarea id="persona_description" name="persona_description" data-i18n="[placeholder]Example: [{{user}} is a 28-year-old Romanian cat girl.]" placeholder="Example:&#10;[{{user}} is a 28-year-old Romanian cat girl.]" class="text_pole textarea_compact" value="" autocomplete="off" rows="8"></textarea>
55455505
55465506 <div class="flex-container justifySpaceBetween">
@@ -6291,7 +6251,7 @@
62916251 <div name="selectChatPopupHeader" class="flex-container alignitemscenter justifySpaceBetween flexGap10">
62926252 <div id="select_chat_import"> <!-- import chat popup header -->
62936253 <form id="form_import_chat" action="javascript:void(null);" method="post" enctype="multipart/form-data" style="display: none;">
62946254 <input type="file" id="chat_import_file" accept=".json, .jsonl" multiple name="avatar">
62956255 <input id="chat_import_file_type" name="file_type" class="text_pole" value="" autocomplete="off" style="display: none;">
62966256 <input id="chat_import_avatar_url" name="avatar_url" class="text_pole" value="" autocomplete="off" style="display: none;">
62976257 <input id="chat_import_character_name" name="character_name" class="text_pole" value="" autocomplete="off" style="display: none;">
@@ -6322,10 +6282,9 @@
63226282 <i class="fa-solid fa-ellipsis-vertical"></i>
63236283 </div>
63246284 <div class="jg-menu">
6325- <div data-action="copy" class="jg-button jg-copy fa-solid fa-file-arrow-up" data-i18n="[title]Copy to system backgrounds" title="Copy to system backgrounds"></div>
6326- <!-- temporarily moved lock icon here (will be moved to header) -->
63276285 <div data-action="lock" class="jg-button jg-lock fa-solid fa-lock fa-fw pointer" data-i18n="[title]Lock" title="Lock"></div>
63286286 <div data-action="unlock" class="jg-button jg-unlock fa-solid fa-lock-open fa-fw pointer" data-i18n="[title]Unlock" title="Unlock"></div>
6287+ <div data-action="copy" class="jg-button jg-copy fa-solid fa-file-arrow-up" data-i18n="[title]Copy to system backgrounds" title="Copy to system backgrounds"></div>
63296288 <div data-action="edit" class="jg-button jg-edit fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename Background" title="Rename Background"></div>
63306289 <div data-action="delete" class="jg-button jg-delete fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete Background" title="Delete Background"></div>
63316290 </div>
@@ -6448,6 +6407,13 @@
64486407 </div>
64496408 </div>
64506409 <div name="perEntryOverridesBlock" class="flex-container wide100p alignitemscenter">
6410+ <div class="world_entry_form_control flex1" title="Set the outlet name for this WI entry.&#10;&#10;WI entries with position 'outlet' will not be added to the prompt automatically. Instead, they will be collected and can be used as a macro in the prompt.&#10;Add {{outlet::YourName}} to the prompt in any place you want to add all WI entries this specific outlet." data-i18n="[title]wi_outlet_name">
6411+ <small class="textAlignCenter">
6412+ <span data-i18n="Outlet Name">Outlet Name</span>
6413+ <div class="fa-solid fa-circle-info opacity50p"></div>
6414+ </small>
6415+ <input class="text_pole margin0" name="outletName" type="text" placeholder="Outlet Name" data-i18n="[placeholder]Outlet Name">
6416+ </div>
64516417 <div class="world_entry_form_control flex1">
64526418 <small class="textAlignCenter" data-i18n="Scan Depth">Scan Depth</small>
64536419 <input class="text_pole margin0" name="scanDepth" type="number" placeholder="Use global setting" data-i18n="[placeholder]Use global setting" max="1000">
@@ -6785,6 +6751,9 @@
67856751 <option value="4" data-role="2" data-i18n="at Depth AI">
67866752 @D 🤖
67876753 </option>
6754+ <option value="7" data-role="" data-i18n="Outlet">
6755+ ➡️ Outlet
6756+ </option>
67886757 </select>
67896758 </div>
67906759 <div class="world_entry_form_control wi-enter-footer-text flex-container flexNoGap">
public/lib/pagination.js+69 -3
@@ -139,9 +139,9 @@
139139 return el;
140140 },
141141
142142 getPageLinkTag: function(indextext) {
143143 var pageLink = attributes.pageLink;
144144 return pageLink ? `<a href="${pageLink}">${indextext}</a>` : `<a>${indextext}</a>`;
145145 },
146146
147147 // Generate HTML for page numbers
@@ -233,6 +233,8 @@
233233
234234 var prevText = attributes.prevText;
235235 var nextText = attributes.nextText;
236+ var firstText = attributes.firstText;
237+ var lastText = attributes.lastText;
236238 var goButtonText = attributes.goButtonText;
237239
238240 var classPrefix = attributes.classPrefix;
@@ -240,6 +242,8 @@
240242 var ulClassName = attributes.ulClassName || '';
241243 var prevClassName = attributes.prevClassName || '';
242244 var nextClassName = attributes.nextClassName || '';
245+ var firstClassName = attributes.firstClassName || '';
246+ var lastClassName = attributes.lastClassName || '';
243247
244248 var html = '';
245249 var sizeSelect = `<select class="J-paginationjs-size-select">`;
@@ -295,9 +299,11 @@
295299 if (showPrevious) {
296300 if (currentPage <= 1) {
297301 if (!autoHidePrevious) {
302+ html += `<li class="${classPrefix}-first ${disableClassName} ${firstClassName}"><a>${firstText}</a></li>`;
298303 html += `<li class="${classPrefix}-prev ${disableClassName} ${prevClassName}"><a>${prevText}</a></li>`;
299304 }
300305 } else {
306+ html += `<li class="${classPrefix}-first J-paginationjs-first ${firstClassName}" data-num="1" title="First page">${getPageLinkTag(firstText)}</li>`;
301307 html += `<li class="${classPrefix}-prev J-paginationjs-previous ${prevClassName}" data-num="${currentPage - 1}" title="Previous page">${getPageLinkTag(prevText)}</li>`;
302308 }
303309 }
@@ -312,9 +318,11 @@
312318 if (currentPage >= totalPage) {
313319 if (!autoHideNext) {
314320 html += `<li class="${classPrefix}-next ${disableClassName} ${nextClassName}"><a>${nextText}</a></li>`;
321+ html += `<li class="${classPrefix}-last ${disableClassName} ${lastClassName}"><a>${lastText}</a></li>`;
315322 }
316323 } else {
317324 html += `<li class="${classPrefix}-next J-paginationjs-next ${nextClassName}" data-num="${currentPage + 1}" title="Next page">${getPageLinkTag(nextText)}</li>`;
325+ html += `<li class="${classPrefix}-last J-paginationjs-last ${lastClassName}" data-num="${totalPage}" title="Last page">${getPageLinkTag(lastText)}</li>`;
318326 }
319327 }
320328 html += `</ul></div>`;
@@ -542,6 +550,14 @@
542550 this.go(this.model.pageNumber + 1, callback);
543551 },
544552
553+ first: function(callback) {
554+ this.go(1, callback);
555+ },
556+
557+ last: function(callback) {
558+ this.go(this.model.totalPage, callback);
559+ },
560+
545561 disable: function() {
546562 var self = this;
547563 var source = self.isAsync ? 'async' : 'sync';
@@ -774,6 +790,38 @@
774790 if (!attributes.pageLink) return false;
775791 });
776792
793+ // First button click listener
794+ el.on('click', '.J-paginationjs-first', function(event) {
795+ var current = $(event.currentTarget);
796+ var pageNumber = current.attr('data-num').trim();
797+
798+ if (!pageNumber || current.hasClass(attributes.disableClassName)) return;
799+
800+ if (self.callHook('beforeFirstOnClick', event, pageNumber) === false) return false;
801+
802+ self.go(pageNumber);
803+
804+ self.callHook('afterFirstOnClick', event, pageNumber);
805+
806+ if (!attributes.pageLink) return false;
807+ });
808+
809+ // Last button click listener
810+ el.on('click', '.J-paginationjs-last', function(event) {
811+ var current = $(event.currentTarget);
812+ var pageNumber = current.attr('data-num').trim();
813+
814+ if (!pageNumber || current.hasClass(attributes.disableClassName)) return;
815+
816+ if (self.callHook('beforeLastOnClick', event, pageNumber) === false) return false;
817+
818+ self.go(pageNumber);
819+
820+ self.callHook('afterLastOnClick', event, pageNumber);
821+
822+ if (!attributes.pageLink) return false;
823+ });
824+
777825 // Go button click listener
778826 el.on('click', '.J-paginationjs-go-button', function(event) {
779827 var pageNumber = $('.J-paginationjs-go-pagenumber', el).val();
@@ -833,6 +881,16 @@
833881 self.next(done);
834882 });
835883
884+ // First page
885+ container.on(eventPrefix + 'first', function(event, done) {
886+ self.first(done);
887+ });
888+
889+ // Last page
890+ container.on(eventPrefix + 'last', function(event, done) {
891+ self.last(done);
892+ });
893+
836894 // Disable
837895 container.on(eventPrefix + 'disable', function() {
838896 self.disable();
@@ -892,6 +950,8 @@
892950 switch (options) {
893951 case 'previous':
894952 case 'next':
953+ case 'first':
954+ case 'last':
895955 case 'go':
896956 case 'disable':
897957 case 'enable':
@@ -991,6 +1051,12 @@
9911051 // 'Next' text
9921052 nextText: '&rsaquo;',
9931053
1054+ // 'First' text
1055+ firstText: '&laquo;',
1056+
1057+ // 'Last' text
1058+ lastText: '&raquo;',
1059+
9941060 // Ellipsis text
9951061 ellipsisText: '...',
9961062
@@ -1149,7 +1215,7 @@
11491215
11501216 // uninstall plugin
11511217 function uninstallPlugin(target) {
11521218 var events = ['go', 'previous', 'next', 'first', 'last', 'disable', 'enable', 'refresh', 'show', 'hide', 'destroy'];
11531219
11541220 // off all events
11551221 $.each(events, function(index, value) {
public/locales/ar-sa.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "وحدة تحكم المطور في Anthropic",
370370 "Claude Model": "نموذج Claude",
371371 "Window AI Model": "نموذج Window AI",
372372 "OpenRouter Model OrderSorting": "فرز نموذج OpenRouter",
373373 "Alphabetically": "أبجديا",
374374 "Price": "السعر (الأرخص)",
375375 "Context Size": "حجم السياق",
public/locales/de-de.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "Anthropics Entwicklerkonsole",
370370 "Claude Model": "Claude-Modell",
371371 "Window AI Model": "Fenster AI-Modell",
372372 "OpenRouter Model OrderSorting": "Sortierung des OpenRouter-Modells",
373373 "Alphabetically": "Alphabetisch",
374374 "Price": "Preis (am günstigsten)",
375375 "Context Size": "Kontextgröße",
public/locales/es-es.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "la consola de desarrolladores de Anthropic",
370370 "Claude Model": "Modelo de Claude",
371371 "Window AI Model": "Modelo de Window AI",
372372 "OpenRouter Model OrderSorting": "Clasificación de modelos de OpenRouter",
373373 "Alphabetically": "Alfabéticamente",
374374 "Price": "Precio (más barato)",
375375 "Context Size": "Tamaño del contexto",
public/locales/fr-fr.json+2 -2
@@ -348,7 +348,7 @@
348348 "Anthropic's developer console": "Console de développement d'Anthropic",
349349 "Claude Model": "Modèle Claude",
350350 "Window AI Model": "Modèle Window AI",
351351 "OpenRouter Model OrderSorting": "Tri des modèles OpenRouter",
352352 "Alphabetically": "Alphabétiquement",
353353 "Price": "Prix ​​(le moins cher)",
354354 "Context Size": "Taille du contexte",
@@ -1566,7 +1566,7 @@
15661566 "These files are available for all characters in the current chat.": "Ces fichiers sont disponibles pour tous les personnages du chat actuel.",
15671567 "Image Captioning": "Légende des images",
15681568 "Local": "Local",
1569- "Multimodal (OpenAI / Anthropic / llama / Google)": "Multimodal (OpenAI / Anthropic / llama / Google)",
1569+ "Multimodal": "Multimodal",
15701570 "Extras": "Extras",
15711571 "Horde": "Horde",
15721572 "API": "API",
public/locales/is-is.json+1 -1
@@ -367,7 +367,7 @@
367367 "Anthropic's developer console": "Uppbyggingaraðilar Forritara stjórnborð",
368368 "Claude Model": "Claude módel",
369369 "Window AI Model": "Vindauga AI módel",
370370 "OpenRouter Model OrderSorting": "OpenRouter líkanaflokkun",
371371 "Alphabetically": "Stafrófsröð",
372372 "Price": "Verð (ódýrast)",
373373 "Context Size": "Samhengisstærð",
public/locales/it-it.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "Console dello sviluppatore di Anthropic",
370370 "Claude Model": "Modello Claude",
371371 "Window AI Model": "Modello AI di Window",
372372 "OpenRouter Model OrderSorting": "Ordinamento dei modelli OpenRouter",
373373 "Alphabetically": "In ordine alfabetico",
374374 "Price": "Prezzo (il più economico)",
375375 "Context Size": "Dimensione del contesto",
public/locales/ja-jp.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "Anthropicの開発者コンソール",
370370 "Claude Model": "Claudeモデル",
371371 "Window AI Model": "Window AIモデル",
372372 "OpenRouter Model OrderSorting": "OpenRouter モデルのソート",
373373 "Alphabetically": "アルファベット順",
374374 "Price": "価格(最安値)",
375375 "Context Size": "コンテキストサイズ",
public/locales/ko-kr.json+1 -1
@@ -374,7 +374,7 @@
374374 "Slack and Poe cookies will not work here, do not bother trying.": "Slack과 Poe 쿠키는 여기서 작동하지 않습니다. 시도하지 마세요.",
375375 "Claude Model": "Claude 모델",
376376 "Window AI Model": "Window AI 모델",
377377 "OpenRouter Model OrderSorting": "OpenRouter 모델 정렬",
378378 "Alphabetically": "알파벳순",
379379 "Price": "가격(가장 저렴)",
380380 "Context Size": "컨텍스트 크기",
public/locales/nl-nl.json+1 -1
@@ -367,7 +367,7 @@
367367 "Anthropic's developer console": "Anthropic's ontwikkelaarsconsole",
368368 "Claude Model": "Claude-model",
369369 "Window AI Model": "Window AI-model",
370370 "OpenRouter Model OrderSorting": "Sorteren van OpenRouter-modellen",
371371 "Alphabetically": "Alfabetisch",
372372 "Price": "Prijs (goedkoopste)",
373373 "Context Size": "Contextgrootte",
public/locales/pt-pt.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "console de desenvolvedor da Anthropic",
370370 "Claude Model": "Modelo Claude",
371371 "Window AI Model": "Modelo Window AI",
372372 "OpenRouter Model OrderSorting": "Classificação de modelo OpenRouter",
373373 "Alphabetically": "Alfabeticamente",
374374 "Price": "Preço (mais barato)",
375375 "Context Size": "Tamanho do contexto",
public/locales/ru-ru.json+121 -29
@@ -24,7 +24,7 @@
2424 "Mirostat_Tau_desc": "Целевая перплексия.",
2525 "Mirostat_Eta_desc": "Скорость обучения Mirostat.",
2626 "Temperature Last": "Температура последней",
2727 "LLaMA / Mistral / Yi models only": "Только для моделей LLaMA / Mistral / Yi. Перед этим обязательно выберите подходящий токенизатор.\nПоследовательности, которых не должно быть на выходе.\nОдна на строку. Текст или [идентификаторы токенов].\nМногие токены имеют пробел впереди. Используйте счетчиксчётчик токенов, если не уверены, что правильно их определяете.",
2828 "Example: some text [42, 69, 1337]": "Пример:\nкакой-то текст\n[42, 69, 1337]",
2929 "Classifier Free Guidance. More helpful tip coming soon": "Classifier Free Guidance. Чуть позже опишем более подробно",
3030 "Scale": "Scale",
@@ -91,7 +91,7 @@
9191 "Impersonation prompt": "Промпт для перевоплощения",
9292 "Prompt that is used for Impersonation function": "Промпт, применяемый при генерации действий от лица пользователя",
9393 "Logit Bias": "Смещение логитов",
9494 "Helps to ban or reenforce the usage of certain words": "Запрещает или поощряет использование определенных слов.",
9595 "View / Edit bias preset": "Просмотр / Редактирование пресета смещения",
9696 "Add bias entry": "Добавить правило смещения",
9797 "Connect": "Подключиться",
@@ -201,8 +201,8 @@
201201 "Bubbles": "Пузыри",
202202 "No Blur Effect": "Отключить размытие",
203203 "No Text Shadows": "Отключить тень текста",
204204 "Waifu Mode": "Режим вайфувизуальной новеллы",
205205 "Message Timer": "ТаймерДлительность генерации сообщений",
206206 "Model Icon": "Значки моделей",
207207 "Advanced Character Search": "Расширенный поиск по персонажам",
208208 "Allow {{char}}: in bot messages": "Показывать {{char}}: в ответах",
@@ -211,12 +211,12 @@
211211 "Lorebook Import Dialog": "Показывать окно импорта лорбука",
212212 "MUI Preset": "Пресет MUI:",
213213 "If set in the advanced character definitions, this field will be displayed in the characters list.": "Если это поле задано в расширенных параметрах персонажа, оно будет отображаться в списке персонажей.",
214214 "Relaxed API URLS": "Смягчённые\"Ленивые\" адреса API",
215215 "Custom CSS": "Пользовательский CSS",
216216 "Mancer Model": "Модель Mancer",
217217 "API Type": "Тип API",
218218 "Aphrodite API key": "Ключ от API Aphrodite",
219219 "Relax message trim in Groups": "МягкаяСмягчить обрезкатребования сообщенийк сообщениям ИИ в группах",
220220 "Characters Hotswap": "HotSwap (смена персонажей на лету)",
221221 "Request token probabilities": "Запрашивать вероятность токена",
222222 "Movable UI Panels": "Подвижные панели UI",
@@ -229,10 +229,10 @@
229229 "Text Shadow Width": "Размер теней текста",
230230 "Swipes": "Свайпы",
231231 "Miscellaneous": "Разное",
232232 "Background Sound Only": "Только фоновыйфоновые звукзвуки",
233233 "Auto-load Last Chat": "Автозагрузка последнего чата",
234234 "Auto-save Message Edits": "Автоматически сохранять отредактированные сообщения",
235235 "Auto-fix Markdown": "Автоисправление разметки Markdown",
236236 "Auto-scroll Chat": "Автоматическая прокрутка чата",
237237 "Send on Enter": "Отправка на Enter",
238238 "Debug Menu": "Меню отладки",
@@ -329,7 +329,7 @@
329329 "Global Lore First": "Сначала глобальный лор",
330330 "Recursive Scan": "Рекурсивное сканирование",
331331 "Case Sensitive": "Учитывать регистр",
332332 "Alert On Overflow": "Оповещение о переполнениипревышении бюджета",
333333 "Use Probability": "Использовать вероятность",
334334 "Exclude from recursion": "Исключить из рекурсии",
335335 "Entry Title/Memo": "Название или заметка о записи",
@@ -354,7 +354,7 @@
354354 "Chat Background": "Фон чата",
355355 "UI Background": "Фон UI",
356356 "Mad Lab Mode": "Режим безумца",
357357 "Show Message Token Count": "СчетчикПоказывать кол-во токенов сообщенияв сообщении",
358358 "Compact Input Area (Mobile)": "Компактная зона ввода",
359359 "Zen Sliders": "Дзен слайдеры",
360360 "UI Border": "Границы UI",
@@ -363,7 +363,7 @@
363363 "Tags as Folders": "Теги как папки",
364364 "Streaming FPS": "FPS для стриминга",
365365 "Gestures": "Жесты",
366366 "Message IDs": "IDНомера сообщений",
367367 "Prefer Character Card Prompt": "Приоритет промпту из карточки персонажа",
368368 "Prefer Character Card Jailbreak": "Приоритет джейлбрейку из карточки персонажа",
369369 "Press Send to continue": "Кнопка отправки продолжает сообщение",
@@ -372,7 +372,7 @@
372372 "Never resize avatars": "Не менять размер аватарок",
373373 "Show avatar filenames": "Показывать названия файлов аватарок",
374374 "Import Card Tags": "Импортировать теги карточки",
375375 "Confirm message deletion": "ПодтверждениеСпрашивать удаленияпри удалении сообщений",
376376 "Spoiler Free Mode": "Режим без спойлеров",
377377 "Auto-swipe": "Автоматические свайпы",
378378 "Minimum generated message length": "Минимальная длина сгенерированных сообщений",
@@ -392,11 +392,11 @@
392392 "Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список действий с сообщением, а не прятать их за '...'.",
393393 "Alternative UI for numeric sampling parameters with fewer steps": "Уменьшить кол-во шагов для параметров, регулируемых слайдерами.",
394394 "Entirely unrestrict all numeric sampling parameters": "Снять ограничения со всех числовых сэмплеров.",
395395 "Time the AI's message generation, and show the duration in the chat log": "ВремяЗасекать генерациивремя, сообщенийза ИИкоторое было сгенерировано сообщение, и егоотображать показэту винформацию журналев чатачате.",
396396 "Show a timestamp for each message in the chat log": "Показывать временную метку для каждого сообщения в журнале чата.",
397397 "Show an icon for the API that generated the message": "ПоказатьПоказывать значок API, сгенерировавшего сообщение.",
398398 "Show sequential message numbers in the chat log": "Показывать порядковые номера сообщений в журнале чатачате.",
399399 "Show the number of tokens in each message in the chat log": "ПоказатьПоказывать количество токенов в каждом сообщении в журнале чатачате.",
400400 "Single-row message input area. Mobile only, no effect on PC": "Однорядная область ввода сообщений. Только для мобильных устройств, на ПК не работает.",
401401 "In the Character Management panel, show quick selection buttons for favorited characters": "На панели управления персонажами будут отображены кнопки быстрого выбора для избранных персонажей.",
402402 "Show tagged character folders in the character list": "Отобразить теговые папки с персонажами в списке персонажей.",
@@ -404,7 +404,7 @@
404404 "Only play a sound when ST's browser tab is unfocused": "Воспроизводить звук только тогда, когда вкладка браузера ST не выбрана.",
405405 "Reduce the formatting requirements on API URLs": "Снижение требований к форматированию URL-адресов API.",
406406 "Ask to import the World Info/Lorebook for every new character with embedded lorebook. If unchecked, a brief message will be shown instead": "Спрашивать разрешение на импорт лорбука для всех персонажей со встроенным лорбуком. При выключенной опции вместо этого будет показываться короткое сообщение",
407407 "Restore unsaved user input on page refresh": "Восстановление несохраненного пользовательского запросасообщения при обновлении страницы.",
408408 "Allow repositioning certain UI elements by dragging them. PC only, no effect on mobile": "Позволяет перемещать некоторые элементы интерфейса путем их перетаскивания. Только для ПК, на телефонах не работает.",
409409 "MovingUI preset. Predefined/saved draggable positions": "Пресет для MovingUI. Предопределенные/сохраненные позиции для перетаскивания.",
410410 "Save movingUI changes to a new file": "Сохранение изменений MovingUI в новый файл.",
@@ -419,7 +419,7 @@
419419 "Show arrow buttons on the last in-chat message to generate alternative AI responses. Both PC and mobile": "Показывать кнопки со стрелками на последнем сообщении в чате, чтобы генерировать альтернативные ответы ИИ. Как для ПК, так и для мобильных устройств.",
420420 "Allow using swiping gestures on the last in-chat message to trigger swipe generation. Mobile only, no effect on PC": "Позволяет использовать жесты смахивания на последнем сообщении в чате, чтобы вызвать альтернативную генерацию. Только для мобильных устройств, на ПК не работает.",
421421 "Save edits to messages without confirmation as you type": "Сохранять правки в сообщениях без подтверждения по мере ввода текста.",
422422 "Skip encoding and characters in message text, allowing a subset of HTML markup as well as Markdown": "Не кодировать символы < и > в тексте сообщения, что позволяет использовать подмножествочасть возможностей HTML-разметки, а также разметку Markdown.",
423423 "Allow AI messages in groups to contain lines spoken by other group members": "Разрешить ИИ в группах генерировать строчки за других участников группы в своих сообщениях.",
424424 "Requests logprobs from the API for the Token Probabilities feature": "Запросить логпробы из API для функции Token Probabilities.",
425425 "Automatically reject and re-generate AI message based on configurable criteria": "Автоматическое отклонение и повторная генерация сообщений AI на основе настраиваемых критериев.",
@@ -549,7 +549,7 @@
549549 "Character Management": "Управление персонажами",
550550 "Locked = Character Management panel will stay open": "Закреплено = Панель управление персонажами останется открытой",
551551 "Select/Create Characters": "Выбрать/Создать персонажа",
552552 "Token counts may be inaccurate and provided just for reference.": "СчетчикСчётчик токенов может быть неточным, используйте как ориентир",
553553 "Click to select a new avatar for this character": "Нажмите чтобы выбрать новый аватар для этого персонажа",
554554 "Example: [{{user}} is a 28-year-old Romanian cat girl.]": "Пример:\n [{{user}} is a 28-year-old Romanian cat girl.]",
555555 "Toggle grid view": "Сменить вид сетки",
@@ -718,7 +718,7 @@
718718 "Google Model": "Модель Google",
719719 "Cohere API Key": "Ключ от API Cohere",
720720 "Cohere Model": "Модель Cohere",
721721 "OpenRouter Model OrderSorting": "Сортировка моделей OpenRouter",
722722 "Alphabetically": "По алфавиту",
723723 "Price": "По цене (наиболее низкая)",
724724 "Context Size": "По размеру контекста",
@@ -1024,10 +1024,10 @@
10241024 "image_inlining_hint_2": ". Также это можно сделать через меню",
10251025 "image_inlining_hint_3": ".",
10261026 "Contest Winners": "Победители конкурса",
10271027 "Rename backgroundBackground": "Переименовать фон",
10281028 "Lock": "Закрепить",
10291029 "Unlock": "Открепить",
10301030 "Delete backgroundBackground": "Удалить фон",
10311031 "Export all": "Экспортировать всё",
10321032 "Export all your prompts to a file": "Экспортировать все промпты в виде файла",
10331033 "Don't add character names.": "Не добавлять имя персонажа",
@@ -1621,7 +1621,7 @@
16211621 "Auxiliary": "Вспомогательный",
16221622 "Post-History Instructions": "Инструкции после истории",
16231623 "Current persona updated": "Текущая персона изменена",
16241624 "Your messages will now be sent as ${0}": "Ваши сообщенияТеперь будутвы отправлятьсябудете отподписываться лицакак ${0}",
16251625 "Copied!": "Скопировано!",
16261626 "Are you sure you want to delete this message?": "Вы точно хотите удалить это сообщение?",
16271627 "Delete Message": "Удалить сообщение",
@@ -1977,7 +1977,7 @@
19771977 "Importing Tags": "Импорт тегов",
19781978 "Couldn't import tags:": "Не удалось импортировать теги:",
19791979 "Allow fallback models": "Разрешить резервные модели",
19801980 "Allow fallback providers": "Разрешить fallback-резервных провайдеров",
19811981 "To use instruct formatting, switch to OpenRouter under Text Completion API.": "Переключитесь на OpenRouter в Text Completion API, чтобы использовать форматирование Instruct-режима.",
19821982 "Select providers. No selection = all providers.": "Выберите провайдера. Нет выбранного = выбраны все.",
19831983 "Model Providers": "Провайдеры моделей",
@@ -2406,7 +2406,7 @@
24062406 "${0} is a system assistant. Choose another character.": "Персонаж ${0} выбран в качестве системного помощника. Выберите другого.",
24072407 "Set ${0} as your assistant.": "Персонаж ${0} установлен в качестве помощника.",
24082408 "${0} is no longer your assistant.": "Персонаж ${0} снят с роли помощника.",
24092409 "Manage API keys": "УправлениеМенеджер ключамиключей",
24102410 "Key:": "Ключ:",
24112411 "No secrets saved.": "Ключей нет.",
24122412 "Add Secret": "Добавить ключ",
@@ -2433,7 +2433,7 @@
24332433 "prompt_post_processing_semi_tools": "Semi-strict (чередовать роли, функции включены)",
24342434 "prompt_post_processing_strict_tools": "Strict (чередовать роли, сначала пользователь; функции включены)",
24352435 "prompt_post_processing_single": "Только одно сообщение пользователя (функции отключены)",
24362436 "prompt_post_processing_merge_tools": "Объединять идущие подряд сообщениесообщения с одной ролью (функции включены)",
24372437 "completions note prefix": "Постфикс ",
24382438 "suffix will be added automatically.": "будет добавлен автоматически.",
24392439 "Tabby Model": "Модель Tabby",
@@ -2548,7 +2548,7 @@
25482548 "Default (top of context)": "По умолчанию (наверху контекста)",
25492549 "Bind Model to Templates": "Связать модель с шаблоном",
25502550 "Persona Auto Selected": "Персона автоматически изменена",
25512551 "Auto-selected persona based on ${0} connection.<br />Your messages will now be sent as ${1}.": "Персона выбрана автоматически на основе следующего критерия: ${0}<br />Ваши сообщенияТеперь будутвы отправлятьсябудете отподписываться лицакак ${1}",
25522552 "Filter to specific generation types.": "Применять промпт только при определённых типах генерации.",
25532553 "Quiet": "Тихая",
25542554 "Swipe": "Свайп",
@@ -2601,7 +2601,7 @@
26012601 "sd_snap_txt": "Корректировать автоматически выбранное разрешение",
26022602 "sd_snap": "Подгонять картинки с фиксированным соотношением сторон (фоны, портреты) к ближайшему известному разрешению (рекомендуется для SDXL)",
26032603 "Source": "API",
26042604 "Hint: Save an API key in AI Horde API settings to use it here.": "ПодсказкаСовет: сохраните ключ от API в настройках API на сайте AI Horde, чтобы использоватьавтоматически подтянуть его автоматическисюда.",
26052605 "Sanitize prompts (recommended)": "Прогонять промпты через санитайзер (рекомендуется)",
26062606 "Sampling method": "Метод сэмплинга",
26072607 "Resolution": "Целевое разрешение",
@@ -2652,5 +2652,97 @@
26522652 "sd_auto_auth_warning_1": " запускайте Stable Diffusion с флагом",
26532653 "sd_auto_auth_warning_2": "! Адрес SD должен быть доступен с сервера SillyTavern.",
26542654 "Upscale by": "Множитель апскейлинга",
26552655 "Hires steps (2nd pass)": "Кол-во шагов Hires (на втором проходе)",
2656+ "Disallow embedded media from other domains in chat messages": "Запретить медиафайлы со сторонних доменов в сообщениях чата.",
2657+ "Add Background": "Добавить фон",
2658+ "Add a new background": "Добавить новый фон",
2659+ "Backgrounds": "Фоны",
2660+ "ext_regex_debugger_desc": "Дополнительные инструменты отладки",
2661+ "ext_regex_debugger": "Отладка",
2662+ "ext_regex_presets": "Пресеты",
2663+ "ext_regex_presets_desc": "Переключайтесь между разными группами активных рег. выражений.",
2664+ "ext_regex_preset_create": "Создать новый пресет",
2665+ "ext_regex_preset_update": "Обновить текущий пресет",
2666+ "ext_regex_preset_apply": "Перезагрузить текущий пресет",
2667+ "ext_regex_preset_delete": "Удалить текущий пресет",
2668+ "[No presets saved]": "[Пресет не выбран]",
2669+ "Enter a name for the new regex preset:": "Введите имя нового пресета:",
2670+ "ext_regex_debugger_active_rules": "Активные скрипты",
2671+ "No regex rules found.": "Скриптов не найдено.",
2672+ "ext_regex_debugger_testing_area": "Зона тестирования",
2673+ "ext_regex_debugger_run_test": "Протестировать",
2674+ "ext_regex_debugger_display_replace": "Заменить",
2675+ "ext_regex_debugger_display_highlight": "Посдветить",
2676+ "ext_regex_debugger_render_text": "Отображать как текст",
2677+ "ext_regex_debugger_render_message": "Отображать как сообщение",
2678+ "ext_regex_debugger_save_order": "Сохранить порядок",
2679+ "ext_regex_debugger_save_order_help": "Сохранить текущий порядок скриптов",
2680+ "Regex script order saved!": "Порядок сохранён!",
2681+ "ext_regex_debugger_raw_input": "Исходный текст",
2682+ "ext_regex_debugger_step_by_step": "Пошаговая трансформация",
2683+ "ext_regex_debugger_final_output": "Итоговый результат",
2684+ "Expand view": "Развернуть",
2685+ "Global Rules": "Глобальные скрипты",
2686+ "Scoped Rules": "Локальные скрипты",
2687+ "Edit Rule": "Редактировать скрипт",
2688+ "Global": "Глобальный",
2689+ "Scoped": "Локальный",
2690+ "Captured:": "Зафиксировано:",
2691+ "Added:": "Добавлено:",
2692+ "Removed:": "Удалено:",
2693+ "Total Captured:": "Всего зафиксировано:",
2694+ "Total Added:": "Всего добавлено:",
2695+ "Total Removed:": "Всего удалено:",
2696+ "ext_regex_debugger_run_test_help": "Прогнать текст через данный набор скриптов",
2697+ "After:": "После:",
2698+ "Are you sure you want to delete this regex preset?": "Точно хотите удалить этот пресет?",
2699+ "Regex preset updated": "Пресет обновлён",
2700+ "Regex preset saved": "Пресет сохранён",
2701+ "Regex preset deleted": "Пресет удалён",
2702+ "Enhance": "Улучшить",
2703+ "Enables prompt enhancing (passes prompts through an LLM to add detail).": "Включить улучшение промпта (дополнительно прогоняет промпт через LLM, чтобы добавить больше деталей)",
2704+ "Use ADetailer (Face)": "Использовать ADetailer (для лиц)",
2705+ "You can find your API key in the Stability AI dashboard.": "Ключ от API можно найти на дашборде на сайте Stability AI",
2706+ "sd_stability_style_preset": "Стиль",
2707+ "Avoid spending Anlas": "Стараться не тратить Anlas",
2708+ "Automatically adjust generation parameters to ensure free image generations.": "Автоматически подгонять параметры таким образом, чтобы генерация получалась бесплатной.",
2709+ "View my Anlas": "Узнать баланс Anlas",
2710+ "Hint: Save an API key in the NovelAI API settings to use it here.": "Совет: сохраните API-ключ в настройках API NovelAI, чтобы автоматически подтянуть его сюда.",
2711+ "Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.": "Совет: сохраните API-ключ в настройках API HuggingFace (раздел Text Completion), чтобы автоматически подтянуть его сюда.",
2712+ "Model ID": "Идентификатор модели",
2713+ "<Enter Model ID above>": "<Введите идентификатор в поле выше>",
2714+ "Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.": "Совет: сохраните API-ключ в настройках API NanoGPT (раздел Chat Completion), чтобы автоматически подтянуть его сюда.",
2715+ "Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.": "Совет: сохраните API-ключ в настройках API Electron Hub (раздел Chat Completion), чтобы автоматически подтянуть его сюда.",
2716+ "sd_drawthings_auth_txt": " запускайте DrawThings только со включенным в UI флажком HTTP API! Сервер должен быть доступен с хоста, на котором запущена SillyTavern.",
2717+ "Send me a picture of:": "Пришли мне...",
2718+ "sd_Yourself": "Свою фотографию",
2719+ "sd_Your_Face": "Своё лицо",
2720+ "sd_Me": "Мою фотографию",
2721+ "sd_The_Whole_Story": "Изображение всей истории",
2722+ "sd_The_Last_Message": "Изображение посл. сообщения",
2723+ "sd_Raw_Last_Message": "Изображение посл. сообщения до обработки",
2724+ "sd_Background": "Фотографию фона",
2725+ "ext_regex_title": "Регулярные выражения",
2726+ "ext_sum_title": "Саммари (пересказ)",
2727+ "Image Generation": "Генерация изображений",
2728+ "ext_translate_title": "Перевод чата",
2729+ "Select TTS Provider": "Выберите TTS-движок",
2730+ "tts_enabled": "Включить",
2731+ "Narrate user messages": "Озвучивать сообщения пользователя",
2732+ "Auto Generation": "Автоматическая генерация",
2733+ "Narrate by paragraphs (when streaming)": "Озвучивать по параграфу за раз (при вкл. стриминге)",
2734+ "Narrate by paragraphs (when not streaming)": "Озвучивать по параграфу за раз (при выкл. стриминге)",
2735+ "Only narrate quotes": "Озвучивать только текст \"в кавычках\"",
2736+ "Ignore text, even quotes, inside asterisk": "Не озвучивать *текст, даже \"в кавычках\", если он внутри звёздочек*",
2737+ "Narrate only the translated text": "Озвучивать только переведённый текст",
2738+ "Skip codeblocks": "Пропускать блоки кода",
2739+ "Skip tagged blocks": "Пропускать блоки с <тегами>",
2740+ "Pass Asterisks to TTS Engine": "Передавать звёздочки TTS-движку",
2741+ "Different voices for quotes and text inside asterisks": "Разные голоса для \"кавычек\", *звёздочек* и остального текста.",
2742+ "Requires auto generation to be enabled.": "Работает только при включенной функции \"Автоматическая генерация\"",
2743+ "tts_refresh": "Обновить",
2744+ "Uses the voices provided by your operating system": "Используем стандартные голоса вашей ОС",
2745+ "Your browser or operating system doesn't support speech synthesis": "Ваш браузер либо ОС не поддерживают синтез речи",
2746+ "Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore *text, even 'quotes', inside asterisks* are disabled.": "Работает лучше всего, когда: одновременно включена опция \"Передавать звёздочки TTS-движку\", а также отключены обе опции: \"Не озвучивать *текст, даже \"в кавычках\", если он внутри звёздочек*\", и \"Озвучивать только текст \"в кавычках\"\"",
2747+ "Available voices": "Доступные голоса"
26562748}
public/locales/uk-ua.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "консолі розробника Anthropic",
370370 "Claude Model": "Модель Claude",
371371 "Window AI Model": "Модель Window AI",
372372 "OpenRouter Model OrderSorting": "Сортування моделі OpenRouter",
373373 "Alphabetically": "За алфавітом",
374374 "Price": "Ціна (найдешевша)",
375375 "Context Size": "Розмір контексту",
public/locales/vi-vn.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "developer console của Anthropic",
370370 "Claude Model": "Model Claude",
371371 "Window AI Model": "Model Window AI",
372372 "OpenRouter Model OrderSorting": "Sắp xếp model OpenRouter",
373373 "Alphabetically": "Theo thứ tự bảng chữ cái",
374374 "Price": "Giá (rẻ nhất)",
375375 "Context Size": "Kích thước bối cảnh",
public/locales/zh-cn.json+19 -7
@@ -452,7 +452,7 @@
452452 "Claude Model": "Claude 模型",
453453 "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。",
454454 "Allow fallback models": "允许后备模型",
455455 "OpenRouter Model OrderSorting": "OpenRouter 模型顺序",
456456 "Alphabetically": "按字母顺序",
457457 "Price": "价格(最便宜)",
458458 "Context Size": "上下文长度",
@@ -1479,7 +1479,7 @@
14791479 "Image Captioning": "图像描述",
14801480 "Source": "来源",
14811481 "Local": "本地",
1482- "Multimodal (OpenAI / Anthropic / llama / Google)": "多模态(OpenAI / Anthropic / llama / Google)",
1482+ "Multimodal": "多模态",
14831483 "Extras": "更多",
14841484 "Horde": "Horde",
14851485 "API": "API",
@@ -1646,8 +1646,9 @@
16461646 "macro for manual injection)": "宏用于手动注入)",
16471647 "Color": "颜色",
16481648 "Only apply color as accent": "仅应用颜色作为强调",
16491649 "ext_regex_new_global_script_desc": "新增「全局」正规表达式正则表达式",
16501650 "ext_regex_new_scoped_script_desc": "新增「局部」正规表达式正则表达式",
1651+ "ext_regex_new_preset_script_desc": "新增「预设」正则表达式",
16511652 "ext_regex_debugger_active_rules": "激活的规则",
16521653 "ext_regex_debugger_save_order": "保存此顺序",
16531654 "ext_regex_debugger_testing_area": "测试区域",
@@ -1668,6 +1669,7 @@
16681669 "ext_regex_preset_delete": "删除当前预设",
16691670 "ext_regex_new_global_script": "新建全局正则",
16701671 "ext_regex_new_scoped_script": "新建局部正则",
1672+ "ext_regex_new_preset_script": "新建预设正则",
16711673 "ext_regex_import_script": "导入正则",
16721674 "ext_regex_bulk_edit": "批量编辑",
16731675 "ext_regex_debugger_desc": "高级正则调试工具",
@@ -1677,9 +1679,13 @@
16771679 "ext_regex_global_scripts_desc": "影响所有角色,保存在本地设定中",
16781680 "No scripts found": "没有找到脚本",
16791681 "ext_regex_scoped_scripts": "局部正则脚本",
1682+ "ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中",
1683+ "ext_regex_preset_scripts": "预设正则脚本",
1684+ "ext_regex_preset_scripts_desc": "只影响当前预设,保存在预设中",
16801685 "ext_regex_disallow_scoped": "不允许使用局部正则",
16811686 "ext_regex_allow_scoped": "允许使用局部正则",
16821687 "ext_regex_scoped_scripts_descext_regex_disallow_preset": "只影响当前角色,保存在角色卡片中不允许使用预设正则",
1688+ "ext_regex_allow_preset": "允许使用预设正则",
16831689 "Regex Editor": "正则表达式编辑器",
16841690 "Test Mode": "测试模式",
16851691 "ext_regex_desc": "“正则”是一个使用“正则表达式”来查找/替换字符串的工具。如果您想了解更多信息,请点击标题旁边的“?”。",
@@ -1728,10 +1734,16 @@
17281734 "ext_regex_disable_script": "禁用脚本",
17291735 "ext_regex_enable_script": "启用脚本",
17301736 "ext_regex_edit_script": "编辑脚本",
17311737 "ext_regex_move_to_global": "移至全局脚本移至全局",
17321738 "ext_regex_move_to_scoped": "移至作用域脚本移至局部",
1739+ "ext_regex_move_to_preset": "移至预设",
17331740 "ext_regex_export_script": "导出脚本",
17341741 "ext_regex_delete_script": "删除脚本",
1742+ "This preset has embedded regex script(s).": "此预设包含内置正则脚本。",
1743+ "Preset '${0}' contains enabled regex scripts": "预设 '${0}' 包含被启用的正则脚本",
1744+ "Reload the chat for regex to take effect": "重新加载聊天以使正则生效",
1745+ "Click here to reload immediately": "点击此处立即重新加载",
1746+ "If you want to do it later, select \"Regex\" from the extensions menu.": "您可稍后从扩展菜单中的(Regex)启用。",
17351747 "Trigger Stable Diffusion": "触发Stable Diffusion",
17361748 "Abort current image generation task": "中止当前图像生成",
17371749 "Stop Image Generation": "停止图像生成",
public/locales/zh-tw.json+2 -2
@@ -368,7 +368,7 @@
368368 "Anthropic's developer console": "Anthropic 的開發者控制台",
369369 "Claude Model": "Claude 模型",
370370 "Window AI Model": "Window AI 模型",
371371 "OpenRouter Model OrderSorting": "模型順序",
372372 "Alphabetically": "按字母順序",
373373 "Price": "價格(最便宜的)",
374374 "Context Size": "上下文長度",
@@ -1674,7 +1674,7 @@
16741674 "Message Template": "訊息範本",
16751675 "Model ID": "模型 ID",
16761676 "mui_reset": "重設",
1677- "Multimodal (OpenAI / Anthropic / llama / Google)": "多模態(OpenAI/Anthropic/llama/Google)",
1677+ "Multimodal": "多模態",
16781678 "must be set in Tabby's config.yml to switch models.": "須在 Tabby's config.yml 中設定以切換模型。",
16791679 "Names as Stop Strings": "將名稱用作停止字串",
16801680 "Never": "從不",
public/manifest.json+10 -0
@@ -25,6 +25,16 @@
2525 "src": "img/apple-icon-144x144.png",
2626 "sizes": "144x144",
2727 "type": "image/png"
28+ },
29+ {
30+ "src": "img/apple-icon-192x192.png",
31+ "sizes": "192x192",
32+ "type": "image/png"
33+ },
34+ {
35+ "src": "img/apple-icon-512x512.png",
36+ "sizes": "512x512",
37+ "type": "image/png"
2838 }
2939 ]
3040}
public/script.js+388 -276
@@ -43,10 +43,11 @@ import {
4343 importEmbeddedWorldInfo,
4444 checkEmbeddedWorld,
4545 setWorldInfoButtonClass,
46- importWorldInfo,
4746 wi_anchor_position,
4847 world_info_include_names,
4948 initWorldInfo,
49+ charUpdatePrimaryWorld,
50+ charSetAuxWorlds,
5051} from './scripts/world-info.js';
5152
5253import {
@@ -175,6 +176,9 @@ import {
175176 localizePagination,
176177 renderPaginationDropdown,
177178 paginationDropdownChangeHandler,
179+ importFromExternalUrl,
180+ shiftUpByOne,
181+ shiftDownByOne,
178182} from './scripts/utils.js';
179183import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids } from './scripts/constants.js';
180184
@@ -267,6 +271,8 @@ import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, ini
267271import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
268272import { event_types, eventSource } from './scripts/events.js';
269273import { initAccessibility } from './scripts/a11y.js';
274+import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
275+import { initDomHandlers } from './scripts/dom-handlers.js';
270276
271277// API OBJECT FOR EXTERNAL WIRING
272278globalThis.SillyTavern = {
@@ -390,7 +396,7 @@ let isExportPopupOpen = false;
390396
391397// Saved here for performance reasons
392398const messageTemplate = $('#message_template .mes');
393399export const chatElement = $('#chat');
394400
395401let dialogueResolve = null;
396402let dialogueCloseStop = false;
@@ -403,6 +409,7 @@ let fav_ch_checked = false;
403409let scrollLock = false;
404410export let abortStatusCheck = new AbortController();
405411export let charDragDropHandler = null;
412+export let chatDragDropHandler = null;
406413
407414/** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
408415export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed;
@@ -637,6 +644,7 @@ async function firstLoadInit() {
637644
638645 showLoader();
639646 registerPromptManagerMigration();
647+ initDomHandlers();
640648 initStandaloneMode();
641649 initLibraryShims();
642650 addShowdownPatch(showdown);
@@ -1344,7 +1352,7 @@ export async function replaceCurrentChat() {
13441352}
13451353
13461354export async function showMoreMessages(messagesToLoad = null) {
13471355 const firstDisplayedMesId = $('#chat')chatElement.children('.mes').first().attr('mesid');
13481356 let messageId = Number(firstDisplayedMesId);
13491357 let count = messagesToLoad || power_user.chat_truncation || Number.MAX_SAFE_INTEGER;
13501358
@@ -1355,7 +1363,7 @@ export async function showMoreMessages(messagesToLoad = null) {
13551363 }
13561364
13571365 console.debug('Inserting messages before', messageId, 'count', count, 'chat length', chat.length);
13581366 const prevHeight = $('#chat')chatElement.prop('scrollHeight');
13591367 const isButtonInView = isElementInViewport($('#show_more_messages')[0]);
13601368
13611369 while (messageId > 0 && count > 0) {
@@ -1370,8 +1378,8 @@ export async function showMoreMessages(messagesToLoad = null) {
13701378 }
13711379
13721380 if (isButtonInView) {
13731381 const newHeight = $('#chat')chatElement.prop('scrollHeight');
13741382 $('#chat')chatElement.scrollTop(newHeight - prevHeight);
13751383 }
13761384
13771385 applyStylePins();
@@ -1384,7 +1392,7 @@ export async function printMessages() {
13841392
13851393 if (chat.length > count) {
13861394 startIndex = chat.length - count;
13871395 $('#chat')chatElement.append('<div id="show_more_messages">Show more messages</div>');
13881396 }
13891397
13901398 for (let i = startIndex; i < chat.length; i++) {
@@ -1407,8 +1415,8 @@ export async function printMessages() {
14071415 }
14081416 }
14091417
14101418 $chatElement.find('#chat .mes').removeClass('last_mes');
14111419 $chatElement.find('#chat .mes').last().addClass('last_mes');
14121420 hideSwipeButtons();
14131421 showSwipeButtons();
14141422 scrollChatToBottom();
@@ -1441,7 +1449,7 @@ export async function clearChat() {
14411449 if (is_delete_mode) {
14421450 $('#dialogue_del_mes_cancel').trigger('click');
14431451 }
14441452 $('#chat')chatElement.children().remove();
14451453 if ($('.zoomed_avatar[forChar]').length) {
14461454 console.debug('saw avatars to remove');
14471455 $('.zoomed_avatar[forChar]').remove();
@@ -1453,7 +1461,7 @@ export async function clearChat() {
14531461
14541462export async function deleteLastMessage() {
14551463 chat.length = chat.length - 1;
14561464 $('#chat')chatElement.children('.mes').last().remove();
14571465 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
14581466}
14591467
@@ -1796,7 +1804,7 @@ function getMessageFromTemplate({
17961804 * @param {boolean} [options.rerenderMessage=true] Whether to re-render the message content (inside <c>.mes_text</c>)
17971805 */
17981806export function updateMessageBlock(messageId, message, { rerenderMessage = true } = {}) {
17991807 const messageElement = $chatElement.find(`#chat [mesid="${messageId}"]`);
18001808 if (rerenderMessage) {
18011809 const text = message?.extra?.display_text ?? message.mes;
18021810 messageElement.find('.mes_text').html(messageFormatting(text, message.name, message.is_system, message.is_user, messageId, {}, false));
@@ -1818,7 +1826,7 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
18181826 // Add image to message
18191827 if (mes.extra?.image) {
18201828 const container = messageElement.find('.mes_img_container');
18211829 const chatHeight = $('#chat')chatElement.prop('scrollHeight');
18221830 const image = messageElement.find('.mes_img');
18231831 const text = messageElement.find('.mes_text');
18241832 const isInline = !!mes.extra?.inline_image;
@@ -1826,10 +1834,10 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
18261834 if (!adjustScroll) {
18271835 return;
18281836 }
18291837 const scrollPosition = $('#chat')chatElement.scrollTop();
18301838 const newChatHeight = $('#chat')chatElement.prop('scrollHeight');
18311839 const diff = newChatHeight - chatHeight;
18321840 $('#chat')chatElement.scrollTop(scrollPosition + diff);
18331841 };
18341842 image.off('load').on('load', function () {
18351843 image.removeAttr('alt');
@@ -1876,16 +1884,16 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
18761884 const container = $('#message_video_template .mes_video_container').clone();
18771885 messageElement.find('.mes_video_container').remove();
18781886 messageElement.find('.mes_block').append(container);
18791887 const chatHeight = $('#chat')chatElement.prop('scrollHeight');
18801888 const video = container.find('.mes_video');
18811889 video.off('loadedmetadata').on('loadedmetadata', function () {
18821890 if (!adjustScroll) {
18831891 return;
18841892 }
18851893 const scrollPosition = $('#chat')chatElement.scrollTop();
18861894 const newChatHeight = $('#chat')chatElement.prop('scrollHeight');
18871895 const diff = newChatHeight - chatHeight;
18881896 $('#chat')chatElement.scrollTop(scrollPosition + diff);
18891897 });
18901898
18911899 video.attr('src', mes.extra?.video);
@@ -1927,7 +1935,7 @@ export function addCopyToCodeBlocks(messageElement) {
19271935 e.stopPropagation();
19281936 });
19291937 copyButton.addEventListener('pointerup', async function () {
19301938 const text = codeBlocks.get(i).innerTexttextContent;
19311939 await copyText(text);
19321940 toastr.info(t`Copied!`, '', { timeOut: 2000 });
19331941 });
@@ -2040,7 +2048,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
20402048 // Callers push the new message to chat before calling addOneMessage
20412049 const newMessageId = typeof forceId == 'number' ? forceId : chat.length - 1;
20422050
20432051 const newMessage = $chatElement.find(`#chat [mesid="${newMessageId}"]`);
20442052 const isSmallSys = mes?.extra?.isSmallSys;
20452053
20462054 if (isSmallSys === true) {
@@ -2102,8 +2110,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
21022110 }
21032111
21042112 if (showSwipes) {
21052113 $chatElement.find('#chat .mes').last().addClass('last_mes');
21062114 $chatElement.find('#chat .mes').eq(-2).removeClass('last_mes');
21072115 hideSwipeButtons();
21082116 showSwipeButtons();
21092117 }
@@ -2252,6 +2260,33 @@ export function substituteParams(content, _name1, _name2, _original, _group, _re
22522260 }
22532261 };
22542262
2263+ const getNotCharValue = () => {
2264+ const currentUser = _name1 ?? name1;
2265+ const currentSpeaker = _name2 ?? name2;
2266+
2267+ // Single character chat
2268+ if (!selected_group) {
2269+ return currentUser;
2270+ }
2271+
2272+ // Group chat
2273+ const members = groups.find(x => x.id === selected_group)?.members;
2274+
2275+ if (!Array.isArray(members)) {
2276+ return currentUser;
2277+ }
2278+
2279+ const memberNames = members
2280+ .map(m => characters.find(c => c.avatar === m)?.name)
2281+ .filter(Boolean); // Filter out any null/undefined names
2282+
2283+ // Filter out the current speaker and add the user
2284+ const otherMembers = memberNames.filter(name => name !== currentSpeaker);
2285+ otherMembers.push(currentUser);
2286+
2287+ return otherMembers.join(', ');
2288+ };
2289+
22552290 if (_replaceCharacterCard) {
22562291 const fields = getCharacterCardFields();
22572292 environment.charPrompt = fields.system || '';
@@ -2281,6 +2316,7 @@ export function substituteParams(content, _name1, _name2, _original, _group, _re
22812316 environment.char = _name2 ?? name2;
22822317 environment.group = environment.charIfNotGroup = getGroupValue(true);
22832318 environment.groupNotMuted = getGroupValue(false);
2319+ environment.notChar = getNotCharValue();
22842320 environment.model = getGeneratingModel();
22852321
22862322 if (additionalMacro && typeof additionalMacro === 'object') {
@@ -2905,7 +2941,11 @@ class StreamingProcessor {
29052941 false,
29062942 );
29072943 if (this.messageTextDom instanceof HTMLElement) {
2908- this.messageTextDom.innerHTML = formattedText;
2944+ if (power_user.stream_fade_in) {
2945+ applyStreamFadeIn(this.messageTextDom, formattedText);
2946+ } else {
2947+ this.messageTextDom.innerHTML = formattedText;
2948+ }
29092949 }
29102950
29112951 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration(), this.timeToFirstToken);
@@ -2925,7 +2965,7 @@ class StreamingProcessor {
29252965 async onFinishStreaming(messageId, text) {
29262966 this.markUIGenStopped();
29272967 await this.onProgressStreaming(messageId, text, true);
29282968 addCopyToCodeBlocks($chatElement.find(`#chat .mes[mesid="${messageId}"]`));
29292969
29302970 await this.reasoningHandler.finish(messageId);
29312971
@@ -3164,6 +3204,11 @@ export async function generateRaw({ prompt = '', api = null, instructOverride =
31643204 // construct final prompt from the input. Can either be a string or an array of chat-style messages.
31653205 prompt = createRawPrompt(prompt, api, instructOverride, quietToLoud, systemPrompt, prefill);
31663206
3207+ // Allow extensions to stop generation before it happens
3208+ const eventAbortController = new AbortController();
3209+ const abortHook = () => eventAbortController.abort(new Error('Cancelled by extension'));
3210+ eventSource.on(event_types.GENERATION_STOPPED, abortHook);
3211+
31673212 try {
31683213 if (responseLengthCustomized) {
31693214 TempResponseLength.save(api, responseLength);
@@ -3171,6 +3216,23 @@ export async function generateRaw({ prompt = '', api = null, instructOverride =
31713216 /** @type {object|any[]} */
31723217 let generateData = {};
31733218
3219+ // Allow extensions to modify the prompt before generation
3220+ // 1. for text completion
3221+ if (typeof prompt === 'string') {
3222+ const eventData = { prompt: prompt, dryRun: false };
3223+ await eventSource.emit(event_types.GENERATE_AFTER_COMBINE_PROMPTS, eventData);
3224+ prompt = eventData.prompt;
3225+ }
3226+ // 2. for chat completion
3227+ if (Array.isArray(prompt)) {
3228+ const eventData = { chat: prompt, dryRun: false };
3229+ await eventSource.emit(event_types.CHAT_COMPLETION_PROMPT_READY, eventData);
3230+ prompt = eventData.chat;
3231+ }
3232+
3233+ // Check if the generation was aborted during the event
3234+ eventAbortController.signal.throwIfAborted();
3235+
31743236 switch (api) {
31753237 case 'kobold':
31763238 case 'koboldhorde':
@@ -3250,6 +3312,7 @@ export async function generateRaw({ prompt = '', api = null, instructOverride =
32503312
32513313 return message;
32523314 } finally {
3315+ eventSource.removeListener(event_types.GENERATION_STOPPED, abortHook);
32533316 if (responseLengthCustomized && TempResponseLength.isCustomized()) {
32543317 TempResponseLength.restore(api);
32553318 TempResponseLength.removeEventHook(api, eventHook);
@@ -3353,7 +3416,7 @@ class TempResponseLength {
33533416 */
33543417function removeLastMessage() {
33553418 return new Promise((resolve) => {
33563419 const lastMes = $('#chat')chatElement.children('.mes').last();
33573420 if (lastMes.length === 0) {
33583421 return resolve();
33593422 }
@@ -3575,18 +3638,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
35753638 creatorNotes,
35763639 } = getCharacterCardFields();
35773640
3578- if (main_api !== 'openai') {
3579- if (power_user.sysprompt.enabled) {
3580- system = power_user.prefer_character_prompt && system
3581- ? substituteParams(system, name1, name2, (power_user.sysprompt.content ?? ''))
3582- : baseChatReplace(power_user.sysprompt.content, name1, name2);
3583- system = isInstruct ? substituteParams(system, name1, name2, power_user.sysprompt.content) : system;
3584- } else {
3585- // Nullify if it's not enabled
3586- system = '';
3587- }
3588- }
3589-
35903641 // Depth prompt (character-specific A/N)
35913642 removeDepthPrompts();
35923643 const groupDepthPrompts = getGroupDepthPrompts(selected_group, Number(this_chid));
@@ -3717,12 +3768,8 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
37173768
37183769 let mesExamplesArray = parseMesExamples(mesExamples, isInstruct);
37193770
3720- //////////////////////////////////
3721- // Extension added strings
37223771 // Set non-WI AN
37233772 setFloatingPrompt();
3724- // Add persona description to prompt
3725- addPersonaDescriptionExtensionPrompt();
37263773
37273774 // Add WI to prompt (and also inject WI to AN value via hijack)
37283775 // Make quiet prompt available for WIAN
@@ -3738,7 +3785,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
37383785 creatorNotes: creatorNotes,
37393786 trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
37403787 };
37413788 const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth, outletEntries } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
37423789 setExtensionPrompt(inject_ids.QUIET_PROMPT, '', extension_prompt_types.IN_PROMPT, 0, true);
37433790
37443791 // Add message example WI
@@ -3770,17 +3817,38 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
37703817 if (skipWIAN !== true) {
37713818 console.log('skipWIAN not active, adding WIAN');
37723819 // Add all depth WI entries to prompt
37733820 flushWIDepthInjectionsflushWIInjections();
37743821 if (Array.isArray(worldInfoDepth)) {
37753822 worldInfoDepth.forEach((e) => {
37763823 const joinedEntries = e.entries.join('\n');
37773824 setExtensionPrompt(inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, extension_prompt_types.IN_CHAT, e.depth, false, e.role);
37783825 });
37793826 }
3827+ if (outletEntries && typeof outletEntries === 'object' && Object.keys(outletEntries).length > 0) {
3828+ Object.entries(outletEntries).forEach(([key, value]) => {
3829+ setExtensionPrompt(inject_ids.CUSTOM_WI_OUTLET(key), value.join('\n'), extension_prompt_types.NONE, 0);
3830+ });
3831+ }
37803832 } else {
37813833 console.log('skipping WIAN');
37823834 }
37833835
3836+ // Add persona description to prompt
3837+ addPersonaDescriptionExtensionPrompt();
3838+
3839+ // Prepare the system prompt for Text Completion APIs
3840+ if (main_api !== 'openai') {
3841+ if (power_user.sysprompt.enabled) {
3842+ system = power_user.prefer_character_prompt && system
3843+ ? substituteParams(system, name1, name2, (power_user.sysprompt.content ?? ''))
3844+ : baseChatReplace(power_user.sysprompt.content, name1, name2);
3845+ system = isInstruct ? substituteParams(system, name1, name2, power_user.sysprompt.content) : system;
3846+ } else {
3847+ // Nullify if it's not enabled
3848+ system = '';
3849+ }
3850+ }
3851+
37843852 // Collect before / after story string injections
37853853 const beforeScenarioAnchor = await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT);
37863854 const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT);
@@ -3845,14 +3913,14 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
38453913 // This operation will result in the injectedIndices indexes being off by one
38463914 coreChat.push({ mes: jailbreak, is_user: true });
38473915 // Add +1 to the elements to correct for the new PHI/Jailbreak message.
3848- injectedIndices.forEach((e, idx) => injectedIndices[idx] = e + 1);
3916+ injectedIndices.forEach(shiftUpByOne);
38493917 }
38503918 }
38513919 }
38523920
38533921 let chat2 = [];
38543922 let continue_mag = '';
38553923 constlet userMessageIndices = [];
38563924 const lastUserMessageIndex = coreChat.findLastIndex(x => x.is_user);
38573925
38583926 for (let i = coreChat.length - 1, j = 0; i >= 0; i--, j++) {
@@ -3951,16 +4019,24 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
39514019 // Only add the chat in context if past the greeting message
39524020 if (isContinue && (chat2.length > 1 || main_api === 'openai')) {
39534021 cyclePrompt = chat2.shift();
4022+ // Adjust indices to account for the shift
4023+ injectedIndices = injectedIndices.map(shiftDownByOne).filter(x => x >= 0);
4024+ userMessageIndices = userMessageIndices.map(shiftDownByOne).filter(x => x >= 0);
39544025 }
39554026
39564027 // Collect enough messages to fill the context
39574028 let arrMes = new Array(chat2.length);
39584029 let tokenCount = await getMessagesTokenCount();
39594030 let lastAddedIndex = -10;
39604031
39614032 // Pre-allocate all injections first.
39624033 // If it doesn't fit - user shot himself in the foot
39634034 for (const index of injectedIndices) {
4035+ // not needed for OAI prompting
4036+ if (main_api == 'openai') {
4037+ break;
4038+ }
4039+
39644040 const item = chat2[index];
39654041
39664042 if (typeof item !== 'string') {
@@ -4394,7 +4470,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
43944470 }
43954471 }
43964472
43974473 await eventSource.emit(event_types.GENERATE_AFTER_DATA, generate_data, dryRun);
43984474
43994475 if (dryRun) {
44004476 return Promise.resolve();
@@ -4703,7 +4779,7 @@ export function stopGeneration() {
47034779 * @returns {Promise<number[]>} Array of indices where the extension prompts were injected
47044780 */
47054781async function doChatInject(messages, isContinue) {
47064782 const injectedIndicesinjectedMessages = [];
47074783 let totalInsertedMessages = 0;
47084784 messages.reverse();
47094785
@@ -4743,18 +4819,21 @@ async function doChatInject(messages, isContinue) {
47434819 const injectIdx = Math.min(depth + totalInsertedMessages, messages.length);
47444820 messages.splice(injectIdx, 0, ...roleMessages);
47454821 totalInsertedMessages += roleMessages.length;
4746- injectedIndices.push(...Array.from({ length: roleMessages.length }, (_, i) => injectIdx + i));
4822+ injectedMessages.push(...roleMessages);
47474823 }
47484824 }
47494825
4826+ const injectedIndices = injectedMessages.map(msg => messages.indexOf(msg));
47504827 messages.reverse();
47514828 return injectedIndices;
47524829}
47534830
47544831function flushWIDepthInjectionsflushWIInjections() {
4755- //prevent custom depth WI entries (which have unique random key names) from duplicating
4832+ const depthPrefix = inject_ids.CUSTOM_WI_DEPTH;
4833+ const outletPrefix = inject_ids.CUSTOM_WI_OUTLET('');
4834+
47564835 for (const key of Object.keys(extension_prompts)) {
47574836 if (key.startsWith(inject_idsdepthPrefix) || key.CUSTOM_WI_DEPTHstartsWith(outletPrefix)) {
47584837 delete extension_prompts[key];
47594838 }
47604839 }
@@ -4775,7 +4854,7 @@ function unblockGeneration(type) {
47754854 showSwipeButtons();
47764855 setGenerationProgress(0);
47774856 flushEphemeralStoppingStrings();
47784857 flushWIDepthInjectionsflushWIInjections();
47794858}
47804859
47814860export function getNextMessageId(type) {
@@ -4977,6 +5056,8 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul
49775056 await populateFileAttachment(message);
49785057 statMesProcess(message, 'user', characters, this_chid, '');
49795058
5059+ chat_metadata['tainted'] = true;
5060+
49805061 if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) {
49815062 chat.splice(insertAt, 0, message);
49825063 await saveChatConditional();
@@ -5110,18 +5191,18 @@ export async function duplicateCharacter() {
51105191}
51115192
51125193function setInContextMessages(msgInContextCount, type) {
51135194 $chatElement.find('#chat .mes').removeClass('lastInContext');
51145195
51155196 if (type === 'swipe' || type === 'regenerate' || type === 'continue') {
51165197 msgInContextCount++;
51175198 }
51185199
51195200 const lastMessageBlock = $chatElement.find('#chat .mes:not([is_system="true"])').eq(-msgInContextCount);
51205201 lastMessageBlock.addClass('lastInContext');
51215202
51225203 if (lastMessageBlock.length === 0) {
51235204 const firstMessageId = getFirstDisplayedMessageId();
51245205 $chatElement.find(`#chat .mes[mesid="${firstMessageId}"`).addClass('lastInContext');
51255206 }
51265207
51275208 // Update last id to chat. No metadata save on purpose, gets hopefully saved via another call
@@ -5679,9 +5760,9 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
56795760 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
56805761 }
56815762 const chat_id = (chat.length - 1);
56825763 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
56835764 addOneMessage(chat[chat_id], { type: 'swipe' });
56845765 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
56855766 } else {
56865767 chat[chat.length - 1]['mes'] = getMessage;
56875768 }
@@ -5703,9 +5784,9 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
57035784 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
57045785 }
57055786 const chat_id = (chat.length - 1);
57065787 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
57075788 addOneMessage(chat[chat_id], { type: 'swipe' });
57085789 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
57095790 } else if (type === 'appendFinal') {
57105791 oldMessage = chat[chat.length - 1]['mes'];
57115792 console.debug('Trying to appendFinal.');
@@ -5724,9 +5805,9 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
57245805 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
57255806 }
57265807 const chat_id = (chat.length - 1);
57275808 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
57285809 addOneMessage(chat[chat_id], { type: 'swipe' });
57295810 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
57305811
57315812 } else {
57325813 console.debug('entering chat update routine for non-swipe post');
@@ -6617,7 +6698,7 @@ async function getChatResult() {
66176698}
66186699
66196700function getFirstMessage() {
66206701 const firstMes = characters[this_chid]?.first_mes || '';
66216702 const alternateGreetings = characters[this_chid]?.data?.alternate_greetings;
66226703
66236704 const message = {
@@ -6639,7 +6720,12 @@ function getFirstMessage() {
66396720
66406721 message['swipe_id'] = 0;
66416722 message['swipes'] = swipes;
66426723 message['swipe_info'] = [];swipes.map(_ => ({
6724+ send_date: message.send_date,
6725+ gen_started: void 0,
6726+ gen_finished: void 0,
6727+ extra: {},
6728+ }));
66436729 }
66446730
66456731 return message;
@@ -6752,7 +6838,7 @@ export function changeMainAPI() {
67526838 $('#ai_module_block_novel').css('display', 'none');
67536839 }
67546840
67556841 $('#prompt_cost_block').toggle(selectedVal === 'textgenerationwebui' && textgen_settings.type === textgen_types.OPENROUTER);
67566842
67576843 // Hide common settings for OpenAI
67586844 console.debug('value?', selectedVal);
@@ -7274,8 +7360,9 @@ export function getCurrentChatDetails() {
72747360 * The function first fetches the chats, processes them, and then displays them in
72757361 * the HTML. It also has a built-in search functionality that allows filtering the
72767362 * displayed chats based on a search query.
7363+ * @param {string[]} hightlightNames - An array of chat names to highlight
72777364 */
72787365export async function displayPastChats(hightlightNames = []) {
72797366 $('#select_chat_div').empty();
72807367 $('#select_chat_search').val('').off('input');
72817368
@@ -7284,10 +7371,10 @@ export async function displayPastChats() {
72847371 const displayName = chatDetails.characterName;
72857372 const avatarImg = chatDetails.avatarImgURL;
72867373
72877374 await displayChats('', currentChat, displayName, avatarImg, selected_group, hightlightNames);
72887375
72897376 const debouncedDisplay = debounce((searchQuery) => {
72907377 displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, []);
72917378 });
72927379
72937380 // Define the search input listener
@@ -7303,7 +7390,7 @@ export async function displayPastChats() {
73037390 }, 200);
73047391}
73057392
73067393async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
73077394 try {
73087395 const trimExtension = (fileName) => String(fileName).replace('.jsonl', '');
73097396
@@ -7343,6 +7430,12 @@ async function displayChats(searchQuery, currentChat, displayName, avatarImg, se
73437430 }
73447431
73457432 $('#select_chat_div').append(template);
7433+
7434+ if (Array.isArray(highlightNames) && highlightNames.includes(chat.file_name)) {
7435+ const templateOffset = template.offset().top - template.parent().offset().top;
7436+ $('#select_chat_div').scrollTop(templateOffset);
7437+ flashHighlight(template, debounce_timeout.extended);
7438+ }
73467439 }
73477440 } catch (error) {
73487441 console.error('Error loading chats:', error);
@@ -7476,7 +7569,7 @@ export function select_rm_info(type, charId, previousCharId = null) {
74767569
74777570/**
74787571 * Selects the right menu for displaying the character editor.
74797572 * @param {number|string} chid Character array index
74807573 * @param {object} [param1] Options for the switch
74817574 * @param {boolean} [param1.switchMenu=true] Whether to switch the menu
74827575 */
@@ -7554,6 +7647,11 @@ export function select_selected_character(chid, { switchMenu = true } = {}) {
75547647 $('#character_media_allowed_icon').toggle(externalMediaState);
75557648 $('#character_media_forbidden_icon').toggle(!externalMediaState);
75567649
7650+ // Update some stuff about the char management dropdown
7651+ $('#character_source').attr('disabled', !getCharacterSource(chid) ? '' : null);
7652+
7653+ eventSource.emit(event_types.CHARACTER_EDITOR_OPENED, chid);
7654+
75577655 saveSettingsDebounced();
75587656}
75597657
@@ -7836,7 +7934,7 @@ export function showSwipeButtons() {
78367934 };
78377935 }
78387936
78397937 const currentMessage = $('#chat')chatElement.children().filter(`[mesid="${chat.length - 1}"]`);
78407938 const swipeId = chat[chat.length - 1].swipe_id;
78417939 const swipeCounterText = formatSwipeCounter((swipeId + 1), chat[chat.length - 1].swipes.length);
78427940 const swipeRight = currentMessage.find('.swipe_right');
@@ -7872,42 +7970,45 @@ export function hideSwipeButtons() {
78727970/**
78737971 * Deletes a swipe from the chat.
78747972 *
78757973 * @param {number?} [swipeId = null] - The ID of the swipe to delete. If not provided, the current swipe will be deleted.
7974+ * @param {number?} [messageId = chat.length - 1] - The ID of the message to delete from. If not provided, the last message will be targeted.
78767975 * @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.
78777976 */
78787977export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
78797978 if (swipeId && (isNaN(swipeId) || swipeId < 0)) {
78807979 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
78817980 return;
78827981 }
78837982
78847983 const lastMessagemessage = chat[chat.length - 1messageId];
78857984 if (!lastMessagemessage || !Array.isArray(lastMessagemessage.swipes) || !lastMessagemessage.swipes.length) {
78867985 toastr.warning(t`No messages to delete swipes from.`);
78877986 return;
78887987 }
78897988
78907989 if (lastMessagemessage.swipes.length <= 1) {
78917990 toastr.warning(t`Can't delete the last swipe.`);
78927991 return;
78937992 }
78947993
78957994 swipeId = swipeId ?? lastMessagemessage.swipe_id;
78967995
78977996 if (swipeId < 0 || swipeId >= lastMessagemessage.swipes.length) {
78987997 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
78997998 return;
79007999 }
79018000
79028001 lastMessagemessage.swipes.splice(swipeId, 1);
79038002
79048003 if (Array.isArray(lastMessagemessage.swipe_info) && lastMessagemessage.swipe_info.length) {
79058004 lastMessagemessage.swipe_info.splice(swipeId, 1);
79068005 }
79078006
79088007 // Select the next swipe, or the one before if it was the last one
79098008 const newSwipeId = Math.min(swipeId, lastMessagemessage.swipes.length - 1);
79108009 syncSwipeToMes(nullmessageId, newSwipeId);
8010+
8011+ await eventSource.emit(event_types.MESSAGE_SWIPE_DELETED, { messageId, swipeId, newSwipeId });
79118012
79128013 await saveChatConditional();
79138014 await reloadCurrentChat();
@@ -7957,9 +8058,11 @@ export async function saveChatConditional() {
79578058/**
79588059 * Saves the chat to the server.
79598060 * @param {FormData} formData Form data to send to the server.
79608061 * @param {EventTargetobject} eventTarget Event target[options={}] toOptions triggerfor the event on.import
8062+ * @param {boolean} [options.refresh] Whether to refresh the group chat list after import
8063+ * @returns {Promise<string[]>} List of imported file names.
79618064 */
79628065async function importCharacterChat(formData, eventTarget{ refresh = true } = {}) {
79638066 const fetchResult = await fetch('/api/chats/import', {
79648067 method: 'POST',
79658068 body: formData,
@@ -7969,26 +8072,25 @@ async function importCharacterChat(formData, eventTarget) {
79698072
79708073 if (fetchResult.ok) {
79718074 const data = await fetchResult.json();
79728075 if (data.res && refresh) {
79738076 await displayPastChats();
79748077 }
8078+ return data?.fileNames || [];
79758079 }
79768080
7977- if (eventTarget instanceof HTMLInputElement) {
8081+ return [];
7978- eventTarget.value = '';
7979- }
79808082}
79818083
79828084function updateViewMessageIds(startFromZero = false) {
79838085 const minId = startFromZero ? 0 : getFirstDisplayedMessageId();
79848086
79858087 $('#chat')chatElement.find('.mes').each(function (index, element) {
79868088 $(element).attr('mesid', minId + index);
79878089 $(element).find('.mesIDDisplay').text(`#${minId + index}`);
79888090 });
79898091
79908092 $chatElement.find('#chat .mes').removeClass('last_mes');
79918093 $chatElement.find('#chat .mes').last().addClass('last_mes');
79928094
79938095 updateEditArrowClasses();
79948096}
@@ -8000,14 +8102,14 @@ export function getFirstDisplayedMessageId() {
80008102}
80018103
80028104function updateEditArrowClasses() {
80038105 $chatElement.find('#chat .mes .mes_edit_up').removeClass('disabled');
80048106 $chatElement.find('#chat .mes .mes_edit_down').removeClass('disabled');
80058107
80068108 if (this_edit_mes_id !== undefined) {
80078109 const down = $chatElement.find(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_down`);
80088110 const up = $chatElement.find(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_up`);
80098111 const lastId = Number($chatElement.find('#chat .mes').last().attr('mesid'));
80108112 const firstId = Number($chatElement.find('#chat .mes').first().attr('mesid'));
80118113
80128114 if (lastId == Number(this_edit_mes_id)) {
80138115 down.addClass('disabled');
@@ -8026,7 +8128,7 @@ function updateEditArrowClasses() {
80268128export function closeMessageEditor(what = 'all') {
80278129 if (what === 'message' || what === 'all') {
80288130 if (this_edit_mes_id) {
80298131 $chatElement.find(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_cancel`).trigger('click');
80308132 }
80318133 }
80328134 if (what === 'reasoning' || what === 'all') {
@@ -8081,61 +8183,16 @@ async function openCharacterWorldPopup() {
80818183 const selectedValue = $(this).val();
80828184 const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN;
80838185 const name = !isNaN(worldIndex) ? world_names[worldIndex] : '';
8084- const previousValue = $('#character_world').val();
8186+ await charUpdatePrimaryWorld(name);
8085- $('#character_world').val(name);
8086-
8087- console.debug('Character world selected:', name);
8088-
8089- if (menu_type == 'create') {
8090- create_save.world = name;
8091- } else {
8092- if (previousValue && !name) {
8093- try {
8094- // Dirty hack to remove embedded lorebook from character JSON data.
8095- const data = JSON.parse(String($('#character_json_data').val()));
8096-
8097- if (data?.data?.character_book) {
8098- data.data.character_book = undefined;
8099- }
8100-
8101- $('#character_json_data').val(JSON.stringify(data));
8102- toastr.info(t`Embedded lorebook will be removed from this character.`);
8103- } catch {
8104- console.error('Failed to parse character JSON data.');
8105- }
8106- }
8107-
8108- await createOrEditCharacter();
8109- }
8110-
8111- setWorldInfoButtonClass(undefined, !!name);
81128187 }
81138188
81148189 function handleExtrasWorldSelect(evt) {
8115- const selectedValues = $(this).val();
8190+ const el = evt?.currentTarget ?? this;
81168191 const selectedWorldsselectedValues = Array$(el).isArrayval(selectedValues) ? selectedValues : [];
81178192 letconst charLoreselected = world_infoArray.charLoreisArray(selectedValues) ?? selectedValues : [];
8118- const tempExtraBooks = selectedWorlds.map((index) => world_names[index]).filter(Boolean);
8193+ const fileName = getCharaFilename(null, {});
81198194 const existingCharIndexnextList = charLoreselected.findIndex(map(e)i => eworld_names[i]).name === fileNamefilter(Boolean);
8120-
8195+ charSetAuxWorlds(fileName, nextList);
8121- if (menu_type == 'create') {
8122- create_save.extra_books = tempExtraBooks;
8123- return;
8124- }
8125-
8126- if (existingCharIndex === -1) {
8127- // Add record only if at least 1 lorebook is selected.
8128- if (tempExtraBooks.length > 0) {
8129- charLore.push({ name: fileName, extraBooks: tempExtraBooks });
8130- }
8131- } else if (tempExtraBooks.length === 0) {
8132- charLore.splice(existingCharIndex, 1);
8133- } else {
8134- charLore[existingCharIndex].extraBooks = tempExtraBooks;
8135- }
8136-
8137- Object.assign(world_info, { charLore: charLore });
8138- saveSettingsDebounced();
81398196 }
81408197
81418198 // --- Populate Dropdowns ---
@@ -8258,7 +8315,7 @@ function addAlternateGreeting(template, greeting, index, getArray, popup) {
82588315 * Creates or edits a character based on the form data.
82598316 * @param {Event} [e] Event that triggered the function call.
82608317 */
82618318export async function createOrEditCharacter(e) {
82628319 $('#rm_info_avatar').html('');
82638320 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
82648321 formData.set('fav', String(fav_ch_checked));
@@ -8701,10 +8758,10 @@ export function swipe_right(_event = null, { source, repeated } = {}) {
87018758 easing: animation_easing,
87028759 queue: false,
87038760 complete: async function () {
87048761 const is_animation_scroll = ($('#chat')chatElement.scrollTop() >= ($('#chat')chatElement.prop('scrollHeight') - $('#chat')chatElement.outerHeight()) - 10);
87058762 //console.log(parseInt(chat[chat.length-1]['swipe_id']));
87068763 //console.log(chat[chat.length-1]['swipes'].length);
87078764 const swipeMessage = $('#chat')chatElement.find('[mesid="' + (chat.length - 1) + '"]');
87088765 if (run_generate && parseInt(chat[chat.length - 1]['swipe_id']) === chat[chat.length - 1]['swipes'].length) {
87098766 //shows "..." while generating
87108767 swipeMessage.find('.mes_text').html('...');
@@ -8737,12 +8794,12 @@ export function swipe_right(_event = null, { source, repeated } = {}) {
87378794 queue: false,
87388795 progress: function () {
87398796 // Scroll the chat down as the message expands
87408797 if (is_animation_scroll) $('#chat')chatElement.scrollTop($('#chat')chatElement[0].scrollHeight);
87418798 },
87428799 complete: function () {
87438800 this_mes_div.css('height', 'auto');
87448801 // Scroll the chat down to the bottom once the animation is complete
87458802 if (is_animation_scroll) $('#chat')chatElement.scrollTop($('#chat')chatElement[0].scrollHeight);
87468803 },
87478804 });
87488805 this_mes_div.children('.mes_block').transition({
@@ -8888,6 +8945,8 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
88888945 return;
88898946 }
88908947
8948+ const exists = preserveFileName ? characters.find(character => character.avatar === preserveFileName) : undefined;
8949+
88918950 const format = ext[1].toLowerCase();
88928951 $('#character_import_file_type').val(format);
88938952 const formData = new FormData();
@@ -8914,10 +8973,20 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
89148973 }
89158974
89168975 if (data.file_name !== undefined) {
8976+ let avatarFileName = `${data.file_name}.png`;
8977+
8978+ // Refresh existing thumbnail
8979+ if (exists && this_chid !== undefined) {
8980+ await fetch(getThumbnailUrl('avatar', avatarFileName), { cache: 'reload' });
8981+ }
8982+
89178983 $('#character_search_bar').val('').trigger('input');
89188984
8919- toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`);
8985+ if (exists) {
8920- let avatarFileName = `${data.file_name}.png`;
8986+ toastr.success(t`Character Replaced: ${String(data.file_name).replace('.png', '')}`);
8987+ } else {
8988+ toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`);
8989+ }
89218990 if (importTags) {
89228991 await importCharactersTags([avatarFileName]);
89238992 selectImportedChar(data.file_name);
@@ -8972,7 +9041,7 @@ export async function doNewChat({ deleteCurrentChat = false } = {}) {
89729041
89739042 if (selected_group) {
89749043 await createNewGroupChat(selected_group);
8975- if (deleteCurrentChat) await deleteGroupChat(selected_group, chat_file_for_del);
9044+ if (deleteCurrentChat) await deleteGroupChat(selected_group, chat_file_for_del, { jumpToNewChat: false }); // don't jump, new chat was already created and jumped to above
89769045 }
89779046 else {
89789047 //RossAscends: added character name to new chat filenames and replaced Date.now() with humanizedDateTime;
@@ -9073,6 +9142,34 @@ export async function renameChat(oldFileName, newName) {
90739142}
90749143
90759144/**
9145+ * Closes the current chat, clearing all associated data and resetting the UI.
9146+ * If a message generation is in progress, it prompts the user to stop it first.
9147+ * @returns {Promise<boolean>} True if the chat was successfully closed, false otherwise.
9148+ */
9149+export async function closeCurrentChat() {
9150+ if (is_send_press == false) {
9151+ await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
9152+ await clearChat();
9153+ chat.length = 0;
9154+ resetSelectedGroup();
9155+ setCharacterId(undefined);
9156+ setCharacterName('');
9157+ setActiveCharacter(null);
9158+ setActiveGroup(null);
9159+ this_edit_mes_id = undefined;
9160+ chat_metadata = {};
9161+ selected_button = 'characters';
9162+ $('#rm_button_selected_ch').children('h2').text('');
9163+ select_rm_characters();
9164+ await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
9165+ return true;
9166+ } else {
9167+ toastr.info(t`Please stop the message generation first.`);
9168+ return false;
9169+ }
9170+}
9171+
9172+/**
90769173 * Forces the update of the chat name for a remote character.
90779174 * @param {string|number} characterId Character ID to update chat name for
90789175 * @param {string} newName New name for the chat
@@ -9137,6 +9234,22 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
91379234 characterKey = [characterKey];
91389235 }
91399236
9237+ const inTempChat = this_chid === undefined && name2 === neutralCharacterName;
9238+ if (inTempChat) {
9239+ const confirmClose = await Popup.show.confirm(
9240+ t`You are currently in a temporary chat.`,
9241+ t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`,
9242+ );
9243+ if (!confirmClose) {
9244+ return;
9245+ }
9246+ }
9247+
9248+ const closeChatResult = await closeCurrentChat();
9249+ if (!closeChatResult) {
9250+ return;
9251+ }
9252+
91409253 for (const key of characterKey) {
91419254 const character = characters.find(x => x.avatar == key);
91429255 if (!character) {
@@ -9743,6 +9856,12 @@ jQuery(async function () {
97439856 });
97449857 });
97459858
9859+ $('#creator_notes_textarea').on('input', function () {
9860+ const notes = String($('#creator_notes_textarea').val());
9861+ const avatar = menu_type === 'create' ? '' : characters[this_chid]?.avatar;
9862+ $('#creator_notes_spoiler').html(formatCreatorNotes(notes, avatar));
9863+ });
9864+
97469865 $('#favorite_button').on('click', function () {
97479866 updateFavButtonState(!fav_ch_checked);
97489867 if (menu_type != 'create') {
@@ -9940,24 +10059,7 @@ jQuery(async function () {
994010059 }
994110060
994210061 else if (id == 'option_close_chat') {
9943- if (is_send_press == false) {
10062+ await closeCurrentChat();
9944- await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
9945- await clearChat();
9946- chat.length = 0;
9947- resetSelectedGroup();
9948- setCharacterId(undefined);
9949- setCharacterName('');
9950- setActiveCharacter(null);
9951- setActiveGroup(null);
9952- this_edit_mes_id = undefined;
9953- chat_metadata = {};
9954- selected_button = 'characters';
9955- $('#rm_button_selected_ch').children('h2').text('');
9956- select_rm_characters();
9957- await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
9958- } else {
9959- toastr.info(t`Please stop the message generation first.`);
9960- }
996110063 }
996210064
996310065 else if (id === 'option_settings') {
@@ -10021,15 +10123,15 @@ jQuery(async function () {
1002110123 });
1002210124
1002310125 if (this_del_mes >= 0) {
1002410126 $chatElement.find(`.mes[mesid="${this_del_mes}"]`).nextAll('div').remove();
1002510127 $chatElement.find(`.mes[mesid="${this_del_mes}"]`).remove();
1002610128 chat.length = this_del_mes;
1002710129 chat_metadata['tainted'] = true;
1002810130 await saveChatConditional();
1002910131 chatElement.scrollTop(chatElement[0].scrollHeight);
1003010132 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
1003110133 $chatElement.find('#chat .mes').removeClass('last_mes');
1003210134 $chatElement.find('#chat .mes').last().addClass('last_mes');
1003310135 } else {
1003410136 console.log('this_del_mes is not >= 0, not deleting');
1003510137 }
@@ -10134,9 +10236,9 @@ jQuery(async function () {
1013410236 return;
1013510237 }*/
1013610238
1013710239 let chatScrollPosition = $('#chat')chatElement.scrollTop();
1013810240 if (this_edit_mes_id !== undefined) {
1013910241 let mes_edited = $chatElement.find(`#chat [mesid="${this_edit_mes_id}"]`).find('.mes_edit_done');
1014010242 if (Number(edit_mes_id) == chat.length - 1) { //if the generating swipe (...)
1014110243 let run_edit = true;
1014210244 if (chat[edit_mes_id]['swipe_id'] !== undefined) {
@@ -10195,7 +10297,7 @@ jQuery(async function () {
1019510297 String(edit_textarea.val()).length,
1019610298 );
1019710299 if (Number(this_edit_mes_id) === chat.length - 1) {
1019810300 $('#chat')chatElement.scrollTop(chatScrollPosition);
1019910301 }
1020010302
1020110303 updateEditArrowClasses();
@@ -10313,7 +10415,7 @@ jQuery(async function () {
1031310415
1031410416 hideSwipeButtons();
1031510417 const targetId = Number(this_edit_mes_id) - 1;
1031610418 const target = $chatElement.find(`#chat .mes[mesid="${targetId}"]`);
1031710419 const root = $(this).closest('.mes');
1031810420
1031910421 if (root.length === 0 || target.length === 0) {
@@ -10342,7 +10444,7 @@ jQuery(async function () {
1034210444
1034310445 hideSwipeButtons();
1034410446 const targetId = Number(this_edit_mes_id) + 1;
1034510447 const target = $chatElement.find(`#chat .mes[mesid="${targetId}"]`);
1034610448 const root = $(this).closest('.mes');
1034710449
1034810450 if (root.length === 0 || target.length === 0) {
@@ -10414,7 +10516,7 @@ jQuery(async function () {
1041410516 if (deleteOnlySwipe) {
1041510517 const message = chat[this_edit_mes_id];
1041610518 const swipe_id = message.swipe_id;
1041710519 await deleteSwipe(swipe_id, Number(this_edit_mes_id));
1041810520 return;
1041910521 }
1042010522
@@ -10519,41 +10621,45 @@ jQuery(async function () {
1051910621 });
1052010622
1052110623 $('#chat_import_file').on('change', async function (e) {
1052210624 const targetElement = /** @type {HTMLInputElement} */ (e.target);
10523- if (!(targetElement instanceof HTMLInputElement)) {
10625+ const formElement = document.getElementById('form_import_chat');
10626+ if (!(targetElement instanceof HTMLInputElement) || !(formElement instanceof HTMLFormElement)) {
1052410627 return;
1052510628 }
10526- const file = targetElement.files[0];
1052710629
10528- if (!file) {
10630+ const importedFileNames = [];
10529- return;
10530- }
1053110631
10532- const ext = file.name.match(/\.(\w+)$/);
10632+ for (const file of targetElement.files) {
10533- if (
10633+ const ext = file.name.match(/\.(\w+)$/);
10534- !ext ||
10634+ const format = ext?.[1]?.toLowerCase();
10535- (ext[1].toLowerCase() != 'json' && ext[1].toLowerCase() != 'jsonl')
10536- ) {
10537- return;
10538- }
1053910635
10540- if (selected_group && file.name.endsWith('.json')) {
10636+ if (!['json', 'jsonl'].includes(format)) {
1054110637 toastr.warning('t`Only SillyTavern\'sJSON ownand formatJSONL isfiles are supported for group chat imports. Sorry!'`);
10542- return;
10638+ continue;
1054310639 }
1054410640
10545- const format = ext[1].toLowerCase();
10641+ if (selected_group && format === 'json') {
10546- $('#chat_import_file_type').val(format);
10642+ toastr.warning(t`Only SillyTavern's own format is supported for group chat imports. Sorry!`);
10643+ continue;
10644+ }
1054710645
10548- const formData = new FormData(/** @type {HTMLFormElement} */($('#form_import_chat').get(0)));
10646+ const formData = new FormData(formElement);
1054910647 formData.appendset('user_namefile_type', name1format);
10550- $('#select_chat_div').html('');
10648+ formData.set('avatar', file);
10649+ formData.set('user_name', name1);
1055110650
10552- if (selected_group) {
10651+ const importFn = selected_group ? importGroupChat : importCharacterChat;
10553- await importGroupChat(formData, e.originalEvent.target);
10652+ const result = await importFn(formData, { refresh: false });
10554- } else {
10653+ importedFileNames.push(...result);
10555- await importCharacterChat(formData, e.originalEvent.target);
10654+ }
10655+
10656+ if (importedFileNames.length > 0) {
10657+ toastr.success(t`Successfully imported ${importedFileNames.length} chat(s).`);
1055610658 }
10659+
10660+ await displayPastChats(importedFileNames);
10661+
10662+ targetElement.value = '';
1055710663 });
1055810664
1055910665 $('#rm_button_group_chats').on('click', function () {
@@ -10775,7 +10881,7 @@ jQuery(async function () {
1077510881 return;
1077610882 }
1077710883 if (isEditVisible && power_user.auto_save_msg_edits === true) {
1077810884 $chatElement.find(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_done`).trigger('click');
1077910885 closeMessageEditor('reasoning');
1078010886 $('#send_textarea').trigger('focus');
1078110887 return;
@@ -10819,27 +10925,66 @@ jQuery(async function () {
1081910925 }
1082010926 } break;
1082110927 case 'replace_update': {
10822- const confirm = await Popup.show.confirm(t`Replace Character`, '<p>' + t`Choose a new character card to replace this character with.` + '</p>' + t`All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.` + '<br />' + t`Proceed?`);
10928+ let onlineUrl = getCharacterSource(this_chid);
10823- if (confirm) {
10929+
10824- async function uploadReplacementCard(e) {
10930+ const POPUP_RESULT_URL = POPUP_RESULT.CUSTOM1, POPUP_RESULT_FILE = POPUP_RESULT.CUSTOM2;
10825- const file = e.target.files[0];
10931+ const result = await Popup.show.confirm(t`Replace Character`,
10932+ `<p>${t`Choose a new character card to replace this character with.`}</p>` +
10933+ `<p>${t`You can also replace this character with the one from the online source.`}${onlineUrl ? `<br />This character was downloaded from: <var>${onlineUrl}</var>` : ''}</p>` +
10934+ `<p>${t`All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.`}<br />${t`Proceed?`}</p>`,
10935+ {
10936+ okButton: false,
10937+ customButtons: [{
10938+ text: t`Replace with URL`,
10939+ result: POPUP_RESULT_URL,
10940+ classes: ['popup-button-ok'],
10941+ }, {
10942+ text: t`Replace with File`,
10943+ result: POPUP_RESULT_FILE,
10944+ classes: ['popup-button-ok'],
10945+ }],
10946+ defaultResult: onlineUrl ? POPUP_RESULT_URL : POPUP_RESULT_FILE,
10947+ });
1082610948
10827- if (!file) {
10949+ // Remember the chat currently selected, so we can reload it after the replacement
10828- return;
10950+ const currentChatFile = characters[this_chid]['chat'];
10829- }
10951+ async function postReplace() {
10952+ await openCharacterChat(currentChatFile);
10953+ }
1083010954
10831- try {
10955+ switch (result) {
10832- const chatFile = characters[this_chid]['chat'];
10956+ case POPUP_RESULT_FILE: {
10833- const data = new Map();
10957+ async function uploadReplacementCard(e) {
10834- data.set(file, characters[this_chid].avatar);
10958+ const file = e.target.files[0];
10835- await processDroppedFiles([file], data);
10959+ if (!file) {
10836- await openCharacterChat(chatFile);
10960+ return;
10837- await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'reload' });
10961+ }
10838- } catch {
10962+
10839- toastr.error('Failed to replace the character card.', 'Something went wrong');
10963+ try {
10964+ const data = new Map();
10965+ data.set(file, characters[this_chid].avatar);
10966+ await processDroppedFiles([file], data);
10967+ await postReplace();
10968+ } catch {
10969+ toastr.error('Failed to replace the character card.', 'Something went wrong');
10970+ }
1084010971 }
10972+ $('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click');
10973+ break;
10974+ }
10975+ case POPUP_RESULT_URL: {
10976+ const inputUrl = await Popup.show.input(t`Replace Character from URL`,
10977+ `<p>${t`Enter the URL of the character card to replace this character with.`}</p>` +
10978+ (onlineUrl ? `<p>${t`This character was downloaded from: <var>${onlineUrl}</var>`}</p>` : ''),
10979+ onlineUrl);
10980+ if (!inputUrl) {
10981+ break;
10982+ }
10983+ onlineUrl = inputUrl;
10984+ await importFromExternalUrl(onlineUrl, { preserveFileName: characters[this_chid].avatar });
10985+ await postReplace();
10986+ break;
1084110987 }
10842- $('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click');
1084310988 }
1084410989 } break;
1084510990 case 'import_tags': {
@@ -10881,7 +11026,6 @@ jQuery(async function () {
1088111026 if (!(e.target instanceof HTMLElement)) {
1088211027 return;
1088311028 }
10884- e.target.focus();
1088511029 e.target.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
1088611030 });
1088711031
@@ -10950,47 +11094,7 @@ jQuery(async function () {
1095011094 const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0);
1095111095
1095211096 for (const url of inputs) {
10953- let request;
11097+ await importFromExternalUrl(url);
10954-
10955- if (isValidUrl(url)) {
10956- console.debug('Custom content import started for URL: ', url);
10957- request = await fetch('/api/content/importURL', {
10958- method: 'POST',
10959- headers: getRequestHeaders(),
10960- body: JSON.stringify({ url }),
10961- });
10962- } else {
10963- console.debug('Custom content import started for Char UUID: ', url);
10964- request = await fetch('/api/content/importUUID', {
10965- method: 'POST',
10966- headers: getRequestHeaders(),
10967- body: JSON.stringify({ url }),
10968- });
10969- }
10970-
10971- if (!request.ok) {
10972- toastr.info(request.statusText, 'Custom content import failed');
10973- console.error('Custom content import failed', request.status, request.statusText);
10974- return;
10975- }
10976-
10977- const data = await request.blob();
10978- const customContentType = request.headers.get('X-Custom-Content-Type');
10979- const fileName = request.headers.get('Content-Disposition').split('filename=')[1].replace(/"/g, '');
10980- const file = new File([data], fileName, { type: data.type });
10981-
10982- switch (customContentType) {
10983- case 'character':
10984- await processDroppedFiles([file]);
10985- break;
10986- case 'lorebook':
10987- await importWorldInfo(file);
10988- break;
10989- default:
10990- toastr.warning('Unknown content type');
10991- console.error('Unknown content type', customContentType);
10992- break;
10993- }
1099411098 }
1099511099 });
1099611100
@@ -11001,6 +11105,14 @@ jQuery(async function () {
1100111105 await processDroppedFiles(files);
1100211106 }, { noAnimation: true });
1100311107
11108+ chatDragDropHandler = new DragAndDropHandler('#select_chat_popup', async (_, event) => {
11109+ const importFile = document.getElementById('chat_import_file');
11110+ if (importFile instanceof HTMLInputElement) {
11111+ importFile.files = event.originalEvent.dataTransfer.files;
11112+ $(importFile).trigger('change');
11113+ }
11114+ });
11115+
1100411116 $('#charListGridToggle').on('click', async () => {
1100511117 doCharListDisplaySwitch();
1100611118 });
public/scripts/RossAscends-mods.js+38 -35
@@ -766,45 +766,48 @@ export function initRossMods() {
766766 }
767767 });
768768
769- // read the state of right Nav Lock and apply to rightnav classlist
769+ if (!isMobile()) { //only read/set pin states on non-mobile devices
770- $(RPanelPin).prop('checked', accountStorage.getItem('NavLockOn') == 'true');
770+ // read the state of right Nav Lock and apply to rightnav classlist
771771 if $(RPanelPin).prop('checked', accountStorage.getItem('NavLockOn') == 'true') {;
772- //console.log('setting pin class via local var');
772+ if (accountStorage.getItem('NavLockOn') == 'true') {
773- $(RightNavPanel).addClass('pinnedOpen');
773+ //console.log('setting pin class via local var');
774774 $(RightNavDrawerIconRightNavPanel).addClass('drawerPinnedOpenpinnedOpen');
775- }
775+ $(RightNavDrawerIcon).addClass('drawerPinnedOpen');
776- if ($(RPanelPin).prop('checked')) {
776+ }
777- console.debug('setting pin class via checkbox state');
777+ if ($(RPanelPin).prop('checked')) {
778- $(RightNavPanel).addClass('pinnedOpen');
778+ console.debug('setting pin class via checkbox state');
779779 $(RightNavDrawerIconRightNavPanel).addClass('drawerPinnedOpenpinnedOpen');
780- }
780+ $(RightNavDrawerIcon).addClass('drawerPinnedOpen');
781- // read the state of left Nav Lock and apply to leftnav classlist
781+ }
782- $(LPanelPin).prop('checked', accountStorage.getItem('LNavLockOn') === 'true');
782+ // read the state of left Nav Lock and apply to leftnav classlist
783783 if $(LPanelPin).prop('checked', accountStorage.getItem('LNavLockOn') === 'true') {;
784- //console.log('setting pin class via local var');
784+ if (accountStorage.getItem('LNavLockOn') == 'true') {
785- $(LeftNavPanel).addClass('pinnedOpen');
785+ //console.log('setting pin class via local var');
786786 $(LeftNavDrawerIconLeftNavPanel).addClass('drawerPinnedOpenpinnedOpen');
787- }
787+ $(LeftNavDrawerIcon).addClass('drawerPinnedOpen');
788- if ($(LPanelPin).prop('checked')) {
788+ }
789- console.debug('setting pin class via checkbox state');
789+ if ($(LPanelPin).prop('checked')) {
790- $(LeftNavPanel).addClass('pinnedOpen');
790+ console.debug('setting pin class via checkbox state');
791791 $(LeftNavDrawerIconLeftNavPanel).addClass('drawerPinnedOpenpinnedOpen');
792- }
792+ $(LeftNavDrawerIcon).addClass('drawerPinnedOpen');
793+ }
793794
794795 // read the state of left Nav Lock and apply to leftnav classlist
795796 $(WIPanelPin).prop('checked', accountStorage.getItem('WINavLockOn') === 'true');
796797 if (accountStorage.getItem('WINavLockOn') == 'true') {
797798 //console.log('setting pin class via local var');
798799 $(WorldInfo).addClass('pinnedOpen');
799800 $(WIDrawerIcon).addClass('drawerPinnedOpen');
800801 }
801802
802803 if ($(WIPanelPin).prop('checked')) {
803804 console.debug('setting pin class via checkbox state');
804805 $(WorldInfo).addClass('pinnedOpen');
805806 $(WIDrawerIcon).addClass('drawerPinnedOpen');
807+ }
806808 }
807809
810+
808811 //save state of Right nav being open or closed
809812 $('#rightNavDrawerIcon').on('click', function () {
810813 if (!$('#rightNavDrawerIcon').hasClass('openIcon')) {
public/scripts/backgrounds.js+237 -167
@@ -3,7 +3,8 @@ import { chat_metadata, eventSource, event_types, generateQuietPrompt, getCurren
33import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
44import { SlashCommand } from './slash-commands/SlashCommand.js';
55import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
66import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop } from './utils.js';
7+import { debounce_timeout } from './constants.js';
78import { t } from './i18n.js';
89import { Popup } from './popup.js';
910
@@ -15,6 +16,11 @@ const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkY
1516const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
1617const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;
1718
19+const THUMBNAIL_COLUMNS_MIN = 2;
20+const THUMBNAIL_COLUMNS_MAX = 8;
21+const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5;
22+const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3;
23+
1824/**
1925 * Storage for frontend-generated background thumbnails.
2026 * This is used to store thumbnails for backgrounds that cannot be generated on the server.
@@ -45,6 +51,53 @@ export let background_settings = {
4551 animation: false,
4652};
4753
54+/**
55+ * Creates a single thumbnail DOM element. The CSS now handles all sizing.
56+ * @param {object} imageData - Data for the image (filename, isCustom).
57+ * @returns {HTMLElement} The created thumbnail element.
58+ */
59+function createThumbnailElement(imageData) {
60+ const bg = imageData.filename;
61+ const isCustom = imageData.isCustom;
62+
63+ const thumbnail = $('#background_template .bg_example').clone();
64+
65+ const clipper = document.createElement('div');
66+ clipper.className = 'thumbnail-clipper lazy-load-background';
67+ clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
68+
69+ const titleElement = thumbnail.find('.BGSampleTitle');
70+ clipper.appendChild(titleElement.get(0));
71+ thumbnail.append(clipper);
72+
73+ const url = generateUrlParameter(bg, isCustom);
74+ const title = isCustom ? bg.split('/').pop() : bg;
75+ const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
76+
77+ thumbnail.attr('title', title);
78+ thumbnail.attr('bgfile', bg);
79+ thumbnail.attr('custom', String(isCustom));
80+ thumbnail.data('url', url);
81+ titleElement.text(friendlyTitle);
82+
83+ return thumbnail.get(0);
84+}
85+
86+/**
87+ * Applies the thumbnail column count to the CSS and updates button states.
88+ * @param {number} count - The number of columns to display.
89+ */
90+function applyThumbnailColumns(count) {
91+ const newCount = Math.max(THUMBNAIL_COLUMNS_MIN, Math.min(count, THUMBNAIL_COLUMNS_MAX));
92+ background_settings.thumbnailColumns = newCount;
93+ document.documentElement.style.setProperty('--bg-thumb-columns', newCount.toString());
94+
95+ $('#bg_thumb_zoom_in').prop('disabled', newCount <= THUMBNAIL_COLUMNS_MIN);
96+ $('#bg_thumb_zoom_out').prop('disabled', newCount >= THUMBNAIL_COLUMNS_MAX);
97+
98+ saveSettingsDebounced();
99+}
100+
48101export function loadBackgroundSettings(settings) {
49102 let backgroundSettings = settings.background;
50103 if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
@@ -56,10 +109,21 @@ export function loadBackgroundSettings(settings) {
56109 if (!Object.hasOwn(backgroundSettings, 'animation')) {
57110 backgroundSettings.animation = false;
58111 }
112+
113+ // If a value is already saved, use it. Otherwise, determine default based on screen size.
114+ let columns = backgroundSettings.thumbnailColumns;
115+ if (!columns) {
116+ const isNarrowScreen = window.matchMedia('(max-width: 480px)').matches;
117+ columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP;
118+ }
119+ background_settings.thumbnailColumns = columns;
120+ applyThumbnailColumns(background_settings.thumbnailColumns);
121+
59122 setBackground(backgroundSettings.name, backgroundSettings.url);
60123 setFittingClass(backgroundSettings.fitting);
61124 $('#background_fitting').val(backgroundSettings.fitting);
62125 $('#background_thumbnails_animation').prop('checked', background_settings.animation);
126+ highlightSelectedBackground();
63127}
64128
65129/**
@@ -68,46 +132,26 @@ export function loadBackgroundSettings(settings) {
68132 */
69133async function forceSetBackground(backgroundInfo) {
70134 saveBackgroundMetadata(backgroundInfo.url);
71- setCustomBackground();
135+ $('#bg1').css('background-image', backgroundInfo.url);
72136
73137 const list = chat_metadata[LIST_METADATA_KEY] || [];
74138 const bg = backgroundInfo.path;
75139 list.push(bg);
76140 chat_metadata[LIST_METADATA_KEY] = list;
77141 saveMetadataDebounced();
78142 await getChatBackgroundsListrenderChatBackgrounds();
79143 highlightNewBackground(bg);
80144 highlightLockedBackground();
81145}
82146
83147async function onChatChanged() {
84- if (hasCustomBackground()) {
148+ const lockedUrl = chat_metadata[BG_METADATA_KEY];
85- setCustomBackground();
86- }
87- else {
88- unsetCustomBackground();
89- }
90-
91- await getChatBackgroundsList();
92- highlightLockedBackground();
93-}
94149
95-async function getChatBackgroundsList() {
150+ $('#bg1').css('background-image', lockedUrl || background_settings.url);
96- const list = chat_metadata[LIST_METADATA_KEY];
97- const listEmpty = !Array.isArray(list) || list.length === 0;
98151
99- $('#bg_custom_content').empty();
152+ renderChatBackgrounds();
100- $('#bg_chat_hint').toggle(listEmpty);
153+ highlightLockedBackground();
101-
154+ highlightSelectedBackground();
102- if (listEmpty) {
103- return;
104- }
105-
106- for (const bg of list) {
107- const template = await getBackgroundFromTemplate(bg, true);
108- $('#bg_custom_content').append(template);
109- }
110- activateLazyLoader();
111155}
112156
113157function getBackgroundPath(fileUrl) {
@@ -115,59 +159,53 @@ function getBackgroundPath(fileUrl) {
115159}
116160
117161function highlightLockedBackground() {
118162 $('.bg_example.locked-background').removeClass('locked-background');
119163
120164 const lockedBackgroundlockedBackgroundUrl = chat_metadata[BG_METADATA_KEY];
121165
122166 if (!lockedBackgroundlockedBackgroundUrl) {
123- return;
167+ $('.bg_example').filter(function () {
168+ return $(this).data('url') === lockedBackgroundUrl;
169+ }).addClass('locked-background');
124170 }
125-
126- $('.bg_example').each(function () {
127- const url = $(this).data('url');
128- if (url === lockedBackground) {
129- $(this).addClass('locked');
130- }
131- });
132171}
133172
134173/**
135174 * Locks the background for the current chat
136175 * @param {Event|null} e Click event
137- * @returns {string} Empty string
138176 */
139177function onLockBackgroundClick(eevent = null) {
140- e?.stopPropagation();
178+ if (!getCurrentChatId()) {
141-
179+ toastr.warning(t`Select a chat to lock the background for it`);
142- const chatName = getCurrentChatId();
180+ return;
143-
144- if (!chatName) {
145- toastr.warning('Select a chat to lock the background for it');
146- return '';
147181 }
148182
149- const relativeBgImage = getUrlParameter(this) ?? background_settings.url;
183+ // Take the global background's URL and save it to the chat's metadata.
184+ const urlToLock = event ? $(event.target).closest('.bg_example').data('url') : background_settings.url;
185+ saveBackgroundMetadata(urlToLock);
186+ $('#bg1').css('background-image', urlToLock);
150187
151- saveBackgroundMetadata(relativeBgImage);
188+ // Update UI states to reflect the new lock.
152- setCustomBackground();
153189 highlightLockedBackground();
154- return '';
155190}
156191
157192/**
158193 * LocksUnlocks the background for the current chat
159194 * @param {Event|null} e Click event_event
160- * @returns {string} Empty string
161195 */
162196function onUnlockBackgroundClick(e_event = null) {
163- e?.stopPropagation();
197+ // Delete the lock from the chat's metadata.
164198 removeBackgroundMetadata();
165- unsetCustomBackground();
199+
200+ // Revert the view to the current global background.
201+ $('#bg1').css('background-image', background_settings.url);
202+
203+ // Update UI states to reflect the removal of the lock.
166204 highlightLockedBackground();
167- return '';
205+ highlightSelectedBackground();
168206}
169207
170208function hasCustomBackgroundisChatBackgroundLocked() {
171209 return chat_metadata[BG_METADATA_KEY];
172210}
173211
@@ -181,54 +219,22 @@ function removeBackgroundMetadata() {
181219 saveMetadataDebounced();
182220}
183221
184-function setCustomBackground() {
185- const file = chat_metadata[BG_METADATA_KEY];
186-
187- // bg already set
188- if (document.getElementById('bg_custom').style.backgroundImage == file) {
189- return;
190- }
191-
192- $('#bg_custom').css('background-image', file);
193-}
194-
195-function unsetCustomBackground() {
196- $('#bg_custom').css('background-image', 'none');
197-}
198-
199222function onSelectBackgroundClick() {
200223 const isCustombgFile = $(this).attr('custombgfile') === 'true';
201224 const relativeBgImagebackgroundCssUrl = getUrlParameter(this);
202-
203- // if clicked on upload button
204- if (!relativeBgImage) {
205- return;
206- }
207225
208- // Automatically lock the background if it's custom or other background is locked
226+ if (isChatBackgroundLocked()) {
209- if (hasCustomBackground() || isCustom) {
227+ // If a background is locked, update the locked background directly
210228 saveBackgroundMetadata(relativeBgImagebackgroundCssUrl);
211- setCustomBackground();
229+ $('#bg1').css('background-image', backgroundCssUrl);
212230 highlightLockedBackground();
231+ } else {
232+ // Otherwise, update the global background setting
233+ setBackground(bgFile, backgroundCssUrl);
213234 }
214- highlightLockedBackground();
215-
216- const customBg = window.getComputedStyle(document.getElementById('bg_custom')).backgroundImage;
217-
218- // Custom background is set. Do not override the layer below
219- if (customBg !== 'none') {
220- return;
221- }
222-
223- const bgFile = $(this).attr('bgfile');
224- const backgroundUrl = getBackgroundPath(bgFile);
225235
226236 // Fetching toUpdate browserUI memoryhighlights to reducereflect flickerthe changes.
227- fetch(backgroundUrl).then(() => {
237+ highlightSelectedBackground();
228- setBackground(bgFile, relativeBgImage);
229- }).catch(() => {
230- console.log('Background could not be set: ' + backgroundUrl);
231- });
232238}
233239
234240async function onCopyToSystemBackgroundClick(e) {
@@ -257,7 +263,7 @@ async function onCopyToSystemBackgroundClick(e) {
257263 const index = list.indexOf(bgNames.oldBg);
258264 list.splice(index, 1);
259265 saveMetadataDebounced();
260266 await getChatBackgroundsListrenderChatBackgrounds();
261267}
262268
263269/**
@@ -382,29 +388,32 @@ async function onDeleteBackgroundClick(e) {
382388 list.splice(index, 1);
383389 }
384390
385- const siblingSelector = '.bg_example:not(#form_bg_download)';
391+ if (bg === background_settings.name) {
386392 const nextBgsiblingSelector = bgToDelete'.next(siblingSelector)bg_example';
387393 const prevBgnextBg = bgToDelete.prevnext(siblingSelector);
388394 const anyBgprevBg = $bgToDelete.prev(siblingSelector);
389395
390396 if (nextBg.length > 0) {
391397 nextBg.trigger('click');
392398 } else if (prevBg.length > 0) {
393399 prevBg.trigger('click');
394400 } else {
395- $(anyBg[Math.floor(Math.random() * anyBg.length)]).trigger('click');
401+ const anyOtherBg = $('.bg_example').not(bgToDelete).first();
402+ if (anyOtherBg.length > 0) {
403+ anyOtherBg.trigger('click');
404+ }
405+ }
396406 }
397407
398408 bgToDelete.remove();
399409
400410 if (url === chat_metadata[BG_METADATA_KEY]) {
401411 removeBackgroundMetadata();
402- unsetCustomBackground();
403412 highlightLockedBackground();
404413 }
405414
406415 if (isCustom) {
407416 await getChatBackgroundsListrenderChatBackgrounds();
408417 saveMetadataDebounced();
409418 }
410419 }
@@ -445,6 +454,47 @@ async function autoBackgroundCommand() {
445454 return '';
446455}
447456
457+/**
458+ * Renders the system backgrounds gallery.
459+ * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
460+ */
461+function renderSystemBackgrounds(backgrounds) {
462+ const sourceList = backgrounds || [];
463+ const container = $('#bg_menu_content');
464+ container.empty();
465+
466+ if (sourceList.length === 0) return;
467+
468+ sourceList.forEach(bg => {
469+ const imageData = { filename: bg, isCustom: false };
470+ const thumbnail = createThumbnailElement(imageData);
471+ container.append(thumbnail);
472+ });
473+
474+ activateLazyLoader();
475+}
476+
477+/**
478+ * Renders the chat-specific (custom) backgrounds gallery.
479+ * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
480+ */
481+function renderChatBackgrounds(backgrounds) {
482+ const sourceList = backgrounds ?? (chat_metadata[LIST_METADATA_KEY] || []);
483+ const container = $('#bg_custom_content');
484+ container.empty();
485+ $('#bg_chat_hint').toggle(!sourceList.length);
486+
487+ if (sourceList.length === 0) return;
488+
489+ sourceList.forEach(bg => {
490+ const imageData = { filename: bg, isCustom: true };
491+ const thumbnail = createThumbnailElement(imageData);
492+ container.append(thumbnail);
493+ });
494+
495+ activateLazyLoader();
496+}
497+
448498export async function getBackgrounds() {
449499 const response = await fetch('/api/backgrounds/all', {
450500 method: 'POST',
@@ -454,12 +504,9 @@ export async function getBackgrounds() {
454504 if (response.ok) {
455505 const { images, config } = await response.json();
456506 Object.assign(THUMBNAIL_CONFIG, config);
457- $('#bg_menu_content').children('div').remove();
507+
458- for (const bg of images) {
508+ renderSystemBackgrounds(images);
459- const template = await getBackgroundFromTemplate(bg, false);
509+ highlightSelectedBackground();
460- $('#bg_menu_content').append(template);
461- }
462- activateLazyLoader();
463510 }
464511}
465512
@@ -481,14 +528,19 @@ function activateLazyLoader() {
481528 lazyLoadObserver = new IntersectionObserver((entries, observer) => {
482529 entries.forEach(entry => {
483530 if (entry.target instanceof HTMLElement && entry.isIntersecting) {
484531 const targetclipper = entry.target;
485532 const bgparentThumbnail = targetclipper.getAttributeclosest('bgfile.bg_example');
486- const isCustom = target.getAttribute('custom') === 'true';
533+
487- resolveImageUrl(bg, isCustom)
534+ if (parentThumbnail) {
488- .then(url => { target.style.backgroundImage = url; })
535+ const bg = parentThumbnail.getAttribute('bgfile');
489- .catch(() => { target.style.backgroundImage = PLACEHOLDER_IMAGE; });
536+ const isCustom = parentThumbnail.getAttribute('custom') === 'true';
490- target.classList.remove('lazy-load-background');
537+ resolveImageUrl(bg, isCustom)
491- observer.unobserve(target);
538+ .then(url => { clipper.style.backgroundImage = url; })
539+ .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
540+ }
541+
542+ clipper.classList.remove('lazy-load-background');
543+ observer.unobserve(clipper);
492544 }
493545 });
494546 }, options);
@@ -529,30 +581,11 @@ async function resolveImageUrl(bg, isCustom) {
529581 return `url("${thumbnailUrl}")`;
530582}
531583
532-/**
533- * Instantiates a background template
534- * @param {string} bg Path to background
535- * @param {boolean} isCustom Whether the background is custom
536- * @returns {Promise<JQuery<HTMLElement>>} Background template
537- */
538-async function getBackgroundFromTemplate(bg, isCustom) {
539- const template = $('#background_template .bg_example').clone();
540- const url = generateUrlParameter(bg, isCustom);
541- const title = isCustom ? bg.split('/').pop() : bg;
542- const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
543-
544- template.attr('title', title);
545- template.attr('bgfile', bg);
546- template.attr('custom', String(isCustom));
547- template.data('url', url);
548- template.addClass('lazy-load-background');
549- template.css('background-image', PLACEHOLDER_IMAGE);
550- template.find('.BGSampleTitle').text(friendlyTitle);
551- return template;
552-}
553-
554584async function setBackground(bg, url) {
555- $('#bg1').css('background-image', url);
585+ // Only change the visual background if one is not locked for the current chat.
586+ if (!isChatBackgroundLocked()) {
587+ $('#bg1').css('background-image', url);
588+ }
556589 background_settings.name = bg;
557590 background_settings.url = url;
558591 saveSettingsDebounced();
@@ -680,25 +713,39 @@ function highlightNewBackground(bg) {
680713 * @param {string} fitting Fitting type
681714 */
682715function setFittingClass(fitting) {
683716 const backgrounds = $('#bg1, #bg_custom');
684717 for (const option of ['cover', 'contain', 'stretch', 'center']) {
685718 backgrounds.toggleClass(option, option === fitting);
686719 }
687720 background_settings.fitting = fitting;
688721}
689722
723+function highlightSelectedBackground() {
724+ $('.bg_example.selected-background').removeClass('selected-background');
725+
726+ // The "selected" highlight should always reflect the global background setting.
727+ const activeUrl = background_settings.url;
728+
729+ if (activeUrl) {
730+ // Find the thumbnail whose data-url attribute matches the active URL
731+ $('.bg_example').filter(function () {
732+ return $(this).data('url') === activeUrl;
733+ }).addClass('selected-background');
734+ }
735+}
736+
690737function onBackgroundFilterInput() {
691738 const filterValue = String($(this'#bg-filter').val()).toLowerCase();
692739 $('#bg_menu_content > div.bg_example, #bg_custom_content > .bg_example').each(function () {
693740 const $bgContentbg = $(this);
694- if ($bgContent.attr('title').toLowerCase().includes(filterValue)) {
741+ const title = $bg.attr('title') || '';
695- $bgContent.show();
742+ const hasMatch = title.toLowerCase().includes(filterValue);
696- } else {
743+ $bg.toggle(hasMatch);
697- $bgContent.hide();
698- }
699744 });
700745}
701746
747+const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
748+
702749export function initBackgrounds() {
703750 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
704751 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
@@ -715,6 +762,11 @@ export function initBackgrounds() {
715762 $context.addClass('mobile-menu-open');
716763 }
717764 })
765+ .off('blur', '.bg_example.mobile-menu-open').on('blur', '.bg_example.mobile-menu-open', function () {
766+ if (!$(this).is(':focus-within')) {
767+ $(this).removeClass('mobile-menu-open');
768+ }
769+ })
718770 .off('click', '.jg-button').on('click', '.jg-button', function (e) {
719771 e.stopPropagation();
720772 const action = $(this).data('action');
@@ -738,18 +790,30 @@ export function initBackgrounds() {
738790 }
739791 });
740792
793+ $('#bg_thumb_zoom_in').on('click', () => {
794+ applyThumbnailColumns(background_settings.thumbnailColumns - 1);
795+ });
796+ $('#bg_thumb_zoom_out').on('click', () => {
797+ applyThumbnailColumns(background_settings.thumbnailColumns + 1);
798+ });
741799 $('#auto_background').on('click', autoBackgroundCommand);
742800 $('#add_bg_button').on('change', onBackgroundUploadSelected);
743801 $('#bg-filter').on('input', onBackgroundFilterInput() => debouncedOnBackgroundFilterInput());
744802 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
745803 name: 'lockbg',
746804 callback: () => onLockBackgroundClick(new CustomEvent('click')),{
805+ onLockBackgroundClick();
806+ return '';
807+ },
747808 aliases: ['bglock'],
748809 helpString: 'Locks a background for the currently selected chat',
749810 }));
750811 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
751812 name: 'unlockbg',
752813 callback: () => onUnlockBackgroundClick(new CustomEvent('click')),{
814+ onUnlockBackgroundClick();
815+ return '';
816+ },
753817 aliases: ['bgunlock'],
754818 helpString: 'Unlocks a background for the currently selected chat',
755819 }));
@@ -774,4 +838,10 @@ export function initBackgrounds() {
774838 await getBackgrounds();
775839 await onChatChanged();
776840 });
841+
842+ setupScrollToTop({
843+ scrollContainerId: 'bg-scrollable-content',
844+ buttonId: 'bg-scroll-top',
845+ drawerId: 'Backgrounds',
846+ });
777847}
public/scripts/chats.js+2 -3
@@ -750,10 +750,9 @@ function getStyleContentsFromMarkdown(text) {
750750 return '';
751751 }
752752
753- const div = document.createElement('div');
754753 const html = converter.makeHtml(substituteParams(text));
755- div.innerHTML = html;
754+ const parsedDocument = new DOMParser().parseFromString(html, 'text/html');
756755 const styleElements = Array.from(divparsedDocument.querySelectorAll('style'));
757756 return styleElements
758757 .filter(s => s.textContent.trim().length > 0)
759758 .map(s => s.textContent.trim())
public/scripts/constants.js+1 -0
@@ -51,6 +51,7 @@ export const inject_ids = {
5151 DEPTH_PROMPT_INDEX: (index) => `DEPTH_PROMPT_${index}`,
5252 CUSTOM_WI_DEPTH: 'customDepthWI',
5353 CUSTOM_WI_DEPTH_ROLE: (depth, role) => `customDepthWI_${depth}_${role}`,
54+ CUSTOM_WI_OUTLET: (key) => `customWIOutlet_${key}`,
5455};
5556
5657export const COMETAPI_IGNORE_PATTERNS = [
public/scripts/custom-request.js+3 -1
@@ -49,6 +49,7 @@ import EventSourceStream from './sse-stream.js';
4949 * @property {string} [custom_url] - Optional custom URL
5050 * @property {string} [reverse_proxy] - Optional reverse proxy URL
5151 * @property {string} [proxy_password] - Optional proxy password
52+ * @property {string} [custom_prompt_post_processing] - Optional custom prompt post-processing
5253 */
5354
5455/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */
@@ -414,7 +415,7 @@ export class ChatCompletionService {
414415 * @param {ChatCompletionPayload} custom
415416 * @returns {ChatCompletionPayload}
416417 */
417418 static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, reverse_proxy, proxy_password, custom_prompt_post_processing, ...props }) {
418419 const payload = {
419420 stream,
420421 messages,
@@ -425,6 +426,7 @@ export class ChatCompletionService {
425426 custom_url,
426427 reverse_proxy,
427428 proxy_password,
429+ custom_prompt_post_processing,
428430 use_makersuite_sysprompt: true,
429431 claude_use_sysprompt: true,
430432 ...props,
public/scripts/dom-handlers.js+66 -0
@@ -0,0 +1,66 @@
1+import { throttle } from './utils.js';
2+
3+export function initDomHandlers() {
4+ handleInputWheel();
5+}
6+
7+/**
8+ * Trap mouse wheel inside of focused number inputs to prevent scrolling their containers.
9+ * Instead of firing wheel events, manually update both slider and input values.
10+ * This also makes wheel work inside Firefox.
11+ */
12+function handleInputWheel() {
13+ const minInterval = 25; // ms
14+
15+ /**
16+ * Update input and slider values based on wheel delta
17+ * @param {HTMLInputElement} input The number input element
18+ * @param {HTMLInputElement|null} slider The associated range input element, if any
19+ * @param {number} deltaY The wheel deltaY value
20+ */
21+ function updateValue(input, slider, deltaY) {
22+ const currentValue = parseFloat(input.value);
23+ const step = parseFloat(input.step);
24+ const min = parseFloat(input.min);
25+ const max = parseFloat(input.max);
26+
27+ // Sanity checks before trying to calculate new value
28+ if (isNaN(currentValue) || isNaN(step) || step <= 0 || deltaY === 0) return;
29+
30+ // Calculate new value based on wheel movement delta (negative = up, positive = down)
31+ let newValue = currentValue + (deltaY > 0 ? -step : step);
32+ // Ensure it's a multiple of step
33+ newValue = Math.round(newValue / step) * step;
34+ // Ensure it's within the min and max range (NaN-aware)
35+ newValue = !isNaN(min) ? Math.max(newValue, min) : newValue;
36+ newValue = !isNaN(max) ? Math.min(newValue, max) : newValue;
37+ // Simple fix for floating point precision issues
38+ newValue = Math.round(newValue * 1e10) / 1e10;
39+
40+ // Update both input and slider values
41+ input.value = newValue.toString();
42+ if (slider) slider.value = newValue.toString();
43+ // Trigger input event (just ONE) to update any listeners
44+ const inputEvent = new Event('input', { bubbles: true });
45+ input.dispatchEvent(inputEvent);
46+ }
47+
48+ const updateValueThrottled = throttle(updateValue, minInterval);
49+
50+ document.addEventListener('wheel', (e) => {
51+ // Try to carefully narrow down if we even need to fire this handler
52+ const input = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
53+ if (input && input.type === 'number' && input.hasAttribute('step')) {
54+ const parent = input.closest('.range-block-range-and-counter') ?? input.closest('div') ?? input.parentElement;
55+ const slider = /** @type {HTMLInputElement} */ (parent?.querySelector('input[type="range"]'));
56+
57+ // Stop propagation for either target
58+ if (e.target === input || (slider && e.target === slider)) {
59+ e.stopPropagation();
60+ e.preventDefault();
61+
62+ updateValueThrottled(input, slider, e.deltaY);
63+ }
64+ }
65+ }, { passive: false });
66+}
public/scripts/events.js+4 -0
@@ -12,6 +12,7 @@ export const event_types = {
1212 MESSAGE_FILE_EMBEDDED: 'message_file_embedded',
1313 MESSAGE_REASONING_EDITED: 'message_reasoning_edited',
1414 MESSAGE_REASONING_DELETED: 'message_reasoning_deleted',
15+ MESSAGE_SWIPE_DELETED: 'message_swipe_deleted',
1516 MORE_MESSAGES_LOADED: 'more_messages_loaded',
1617 IMPERSONATE_READY: 'impersonate_ready',
1718 CHAT_CHANGED: 'chat_id_changed',
@@ -36,6 +37,7 @@ export const event_types = {
3637 OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready',
3738 WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
3839 WORLDINFO_UPDATED: 'worldinfo_updated',
40+ CHARACTER_EDITOR_OPENED: 'character_editor_opened',
3941 CHARACTER_EDITED: 'character_edited',
4042 CHARACTER_PAGE_LOADED: 'character_page_loaded',
4143 CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',
@@ -85,6 +87,8 @@ export const event_types = {
8587 SECRET_EDITED: 'secret_edited',
8688 PRESET_CHANGED: 'preset_changed',
8789 PRESET_DELETED: 'preset_deleted',
90+ PRESET_RENAMED: 'preset_renamed',
91+ PRESET_RENAMED_BEFORE: 'preset_renamed_before',
8892 MAIN_API_CHANGED: 'main_api_changed',
8993 WORLDINFO_ENTRIES_LOADED: 'worldinfo_entries_loaded',
9094};
public/scripts/extensions.js+3 -0
@@ -190,7 +190,10 @@ export const extension_settings = {
190190 regex: [],
191191 /** @type {import('./extensions/regex/index.js').RegexPreset[]} */
192192 regex_presets: [],
193+ /** @type {string[]} */
193194 character_allowed_regex: [],
195+ /** @type {Record<string, string[]>} */
196+ preset_allowed_regex: {},
194197 tts: {},
195198 sd: {
196199 prompts: {},
public/scripts/extensions/assets/index.js+37 -3
@@ -10,7 +10,7 @@ import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
1010import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
1111import { accountStorage } from '../../util/AccountStorage.js';
1212import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
1313import { t, translate } from '../../i18n.js';
1414export { MODULE_NAME };
1515
1616const MODULE_NAME = 'assets';
@@ -60,6 +60,36 @@ const KNOWN_TYPES = {
6060 'blip': t`Blip sounds`,
6161};
6262
63+const EMPTY_AUTHOR = {
64+ name: '',
65+ url: '',
66+};
67+
68+/**
69+ * Extracts the repository author from a given URL.
70+ * @param {string} url - The URL of the repository.
71+ * @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
72+ */
73+function getAuthorFromUrl(url) {
74+ const result = structuredClone(EMPTY_AUTHOR);
75+
76+ try {
77+ const parsedUrl = new URL(url);
78+ const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
79+
80+ // TODO: Handle non-GitHub URLs if needed
81+ if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
82+ result.name = pathSegments[0];
83+ result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
84+ }
85+ }
86+ catch (error) {
87+ console.debug(DEBUG_PREFIX, 'Error parsing URL:', error);
88+ }
89+
90+ return result;
91+}
92+
6393async function downloadAssetsList(url) {
6494 updateCurrentAssets().then(async function () {
6595 fetch(url, { cache: 'no-cache' })
@@ -88,7 +118,8 @@ async function downloadAssetsList(url) {
88118 $('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
89119
90120 for (const type of assetTypes) {
91121 const optiontext = $('<option />', { value: type, text: ttranslate([KNOWN_TYPES[type] || type]) });
122+ const option = $('<option />', { value: type, text: text });
92123 $('#assets_type_select').append(option);
93124 }
94125
@@ -184,10 +215,11 @@ async function downloadAssetsList(url) {
184215 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
185216 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
186217 const toolTag = assetType === 'extension' && asset['tool'];
218+ const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
187219
188220 const assetBlock = $('<i></i>')
189221 .append(element)
190222 .append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden">
191223 <span class="asset-name flex-container alignitemscenter">
192224 <b>${displayName}</b>
193225 <a class="asset_preview" href="${url}" target="_blank" title="${title}">
@@ -195,6 +227,8 @@ async function downloadAssetsList(url) {
195227 </a>` +
196228 (toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
197229 t`Tool` + '</span>' : '') +
230+ '<span class="expander"></span>' +
231+ (author.name ? `<a href="${author.url}" target="_blank" class="asset-author-info"><i class="fa-solid fa-at fa-xs"></i><span>${author.name}</span></a>` : '') +
198232 `</span>
199233 <small class="asset-description">
200234 ${description}
public/scripts/extensions/assets/style.css+27 -2
@@ -35,14 +35,15 @@
3535 color: inherit;
3636}
3737
3838.assets-list-div > i {
3939 display: flex;
4040 flex-direction: row;
4141 align-items: center;
4242 justify-content: left;
4343 padding: 10px 5px;
4444 font-style: normal;
4545 gap: 5px;
46+ border-bottom: 1px solid var(--SmartThemeBorderColor);
4647}
4748
4849.assets-list-div i span:first-of-type {
@@ -173,3 +174,27 @@
173174 opacity: 0.9;
174175 margin-left: 2px;
175176}
177+
178+.asset-name .asset-author-info {
179+ display: flex;
180+ align-items: baseline;
181+ gap: 2px;
182+ opacity: 0.7;
183+ font-size: 0.85em;
184+ overflow: hidden;
185+}
186+
187+.asset-name .asset-author-info>span {
188+ white-space: nowrap;
189+ overflow: hidden;
190+ text-overflow: ellipsis;
191+}
192+
193+.asset-name .asset-author-info:hover {
194+ opacity: 1;
195+ transition: opacity var(--animation-duration) ease-in-out;
196+}
197+
198+.asset-name>b {
199+ font-weight: 600;
200+}
public/scripts/extensions/caption/index.js+9 -1
@@ -1,6 +1,6 @@
11import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
22import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
33import { appendMediaToMessage, chat_metadata, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
44import { getMessageTimeStamp } from '../../RossAscends-mods.js';
55import { SECRET_KEYS, secret_state } from '../../secrets.js';
66import { getMultimodalCaption } from '../shared.js';
@@ -174,6 +174,7 @@ async function sendCaptionedMessage(caption, image) {
174174 inline_image: !!extension_settings.caption.show_in_chat,
175175 },
176176 };
177+ chat_metadata['tainted'] = true;
177178 context.chat.push(message);
178179 const messageId = context.chat.length - 1;
179180 await eventSource.emit(event_types.MESSAGE_SENT, messageId);
@@ -549,6 +550,8 @@ jQuery(async function () {
549550 await processEndpoint('pollinations', '/api/backends/chat-completions/multimodal-models/pollinations');
550551 await processEndpoint('nanogpt', '/api/backends/chat-completions/multimodal-models/nanogpt');
551552 await processEndpoint('electronhub', '/api/backends/chat-completions/multimodal-models/electronhub');
553+ await processEndpoint('mistral', '/api/backends/chat-completions/multimodal-models/mistral');
554+ await processEndpoint('xai', '/api/backends/chat-completions/multimodal-models/xai');
552555 }
553556
554557 await addSettings();
@@ -626,6 +629,11 @@ jQuery(async function () {
626629 extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();
627630 saveSettingsDebounced();
628631 });
632+ $('#caption_refresh_models').on('click', async () => {
633+ extension_settings.caption.multimodal_model = '';
634+ await switchMultimodalBlocks();
635+ saveSettingsDebounced();
636+ });
629637
630638 const onMessageEvent = async (index) => {
631639 if (!extension_settings.caption.auto_mode) {
public/scripts/extensions/caption/settings.html+24 -45
@@ -9,7 +9,7 @@
99 <label for="caption_source" data-i18n="Source">Source</label>
1010 <select id="caption_source" class="text_pole">
1111 <option value="local" data-i18n="Local">Local</option>
1212 <option value="multimodal" data-i18n="Multimodal (OpenAI / Anthropic / llama / Google)">Multimodal (OpenAI / Anthropic / llama / Google)</option>
1313 <option value="extras" data-i18n="Extras">Extras (deprecated)</option>
1414 <option value="horde" data-i18n="Horde">Horde</option>
1515 </select>
@@ -18,7 +18,7 @@
1818 <label for="caption_multimodal_api" data-i18n="API">API</label>
1919 <select id="caption_multimodal_api" class="flex1 text_pole">
2020 <option value="aimlapi">AI/ML API</option>
2121 <option value="anthropic">AnthropicClaude</option>
2222 <option value="cohere">Cohere</option>
2323 <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
2424 <option value="electronhub">Electron Hub</option>
@@ -40,23 +40,17 @@
4040 </select>
4141 </div>
4242 <div class="flex1 flex-container flexFlowColumn flexNoGap">
4343 <label for="caption_multimodal_model" data-i18nclass="Modelflex-container justifySpaceBetween">Model</label>
44+ <span data-i18n="Model">Model</span>
45+ <div id="caption_refresh_models" class="right_menu_button margin0 padding0" title="Refresh model list" data-i18n="[title]Refresh model list">
46+ <i class="fa-solid fa-sync"></i>
47+ </div>
48+ </label>
4449 <select id="caption_multimodal_model" class="flex1 text_pole">
4550 <!-- AI/ML API, OpenRouter, Pollinations, NanoGPT, Mistral, xAI are added externally by JavaScript -->
4651 <option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>
4752 <option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>
4853 <option data-type="cohere" value="command-a-vision-07-2025">command-a-vision-07-2025</option>
49- <option data-type="mistral" value="pixtral-12b-latest">pixtral-12b-latest</option>
50- <option data-type="mistral" value="pixtral-12b-2409">pixtral-12b-2409</option>
51- <option data-type="mistral" value="pixtral-large-latest">pixtral-large-latest</option>
52- <option data-type="mistral" value="pixtral-large-2411">pixtral-large-2411</option>
53- <option data-type="mistral" value="mistral-large-pixtral-2411">mistral-large-pixtral-2411</option>
54- <option data-type="mistral" value="mistral-small-2503">mistral-small-2503</option>
55- <option data-type="mistral" value="mistral-small-2506">mistral-small-2506</option>
56- <option data-type="mistral" value="mistral-small-latest">mistral-small-latest</option>
57- <option data-type="mistral" value="mistral-medium-latest">mistral-medium-latest</option>
58- <option data-type="mistral" value="mistral-medium-2505">mistral-medium-2505</option>
59- <option data-type="mistral" value="mistral-medium-2508">mistral-medium-2508</option>
6054 <option data-type="moonshot" value="moonshot-v1-8k-vision-preview">moonshot-v1-8k-vision-preview</option>
6155 <option data-type="moonshot" value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>
6256 <option data-type="moonshot" value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>
@@ -87,6 +81,10 @@
8781 <option data-type="openai" value="o4-mini-2025-04-16">o4-mini-2025-04-16</option>
8882 <option data-type="openai" value="gpt-4.5-preview">gpt-4.5-preview</option>
8983 <option data-type="openai" value="gpt-4.5-preview-2025-02-27">gpt-4.5-preview-2025-02-27</option>
84+ <option data-type="anthropic" value="claude-sonnet-4-5">claude-sonnet-4-5</option>
85+ <option data-type="anthropic" value="claude-sonnet-4-5-20250929">claude-sonnet-4-5-20250929</option>
86+ <option data-type="anthropic" value="claude-haiku-4-5">claude-haiku-4-5</option>
87+ <option data-type="anthropic" value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
9088 <option data-type="anthropic" value="claude-opus-4-1">claude-opus-4-1</option>
9189 <option data-type="anthropic" value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>
9290 <option data-type="anthropic" value="claude-opus-4-0">claude-opus-4-0</option>
@@ -106,49 +104,33 @@
106104 <option data-type="google" value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
107105 <option data-type="google" value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
108106 <option data-type="google" value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
109- <option data-type="google" value="gemini-2.5-pro-exp-03-25">gemini-2.5-pro-exp-03-25</option>
110107 <option data-type="google" value="gemini-2.5-flash">gemini-2.5-flash</option>
108+ <option data-type="google" value="gemini-2.5-flash-preview-09-2025">gemini-2.5-flash-preview-09-2025</option>
111109 <option data-type="google" value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
112- <option data-type="google" value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
113110 <option data-type="google" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
111+ <option data-type="google" value="gemini-2.5-flash-lite-preview-09-2025">gemini-2.5-flash-lite-preview-09-2025</option>
114112 <option data-type="google" value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
113+ <option data-type="google" value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
115114 <option data-type="google" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
116115 <option data-type="google" value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-pro-exp-03-25</option>
117116 <option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-pro-exp-03-25</option>
118117 <option data-type="google" value="gemini-exp-1206">gemini-exp-1206 → 2.5-pro-exp-03-25</option>
119118 <option data-type="google" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
120119 <option data-type="google" value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>
121120 <option data-type="google" value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
122121 <option data-type="google" value="gemini-2.0-flash">gemini-2.0-flash</option>
123122 <option data-type="google" value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21 → 2.5-flash-preview-045-1720</option>
124123 <option data-type="google" value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-0405-1720</option>
125124 <option data-type="google" value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-0405-1720</option>
126125 <option data-type="google" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
127126 <option data-type="google" value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>
128127 <option data-type="google" value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>
129- <option data-type="google" value="gemini-1.5-pro-latest">gemini-1.5-pro-latest</option>
130- <option data-type="google" value="gemini-1.5-pro-002">gemini-1.5-pro-002</option>
131- <option data-type="google" value="gemini-1.5-pro-001">gemini-1.5-pro-001</option>
132- <option data-type="google" value="gemini-1.5-pro">gemini-1.5-pro</option>
133- <option data-type="google" value="gemini-1.5-flash-latest">gemini-1.5-flash-latest</option>
134- <option data-type="google" value="gemini-1.5-flash-002">gemini-1.5-flash-002</option>
135- <option data-type="google" value="gemini-1.5-flash-001">gemini-1.5-flash-001</option>
136- <option data-type="google" value="gemini-1.5-flash">gemini-1.5-flash</option>
137- <option data-type="google" value="gemini-1.5-flash-8b-latest">gemini-1.5-flash-8b-latest</option>
138- <option data-type="google" value="gemini-1.5-flash-8b-001">gemini-1.5-flash-8b-001</option>
139- <option data-type="google" value="gemini-1.5-flash-8b-exp-0924">gemini-1.5-flash-8b-exp-0924</option>
140- <option data-type="google" value="gemini-1.5-flash-8b-exp-0827">gemini-1.5-flash-8b-exp-0827</option>
141128 <option data-type="google" value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>
142129 <option data-type="google" value="learnlmgemini-robotics-er-1.5-pro-experimentalpreview">learnlmgemini-robotics-er-1.5-pro-experimentalpreview</option>
143130 <option data-type="vertexai" value="gemini-2.5-pro">gemini-2.5-pro</option>
144- <option data-type="vertexai" value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
145- <option data-type="vertexai" value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
146- <option data-type="vertexai" value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
147131 <option data-type="vertexai" value="gemini-2.5-flash">gemini-2.5-flash</option>
148- <option data-type="vertexai" value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
149- <option data-type="vertexai" value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option>
150132 <option data-type="vertexai" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
151133 <option data-type="vertexai" value="gemini-2.5-flash-lite-preview-06-17image">gemini-2.5-flash-lite-preview-06-17image</option>
152134 <option data-type="vertexai" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
153135 <option data-type="vertexai" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
154136 <option data-type="vertexai" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
@@ -174,9 +156,6 @@
174156 <option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
175157 <option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
176158 <option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>
177- <option data-type="xai" value="grok-4-0709">grok-4-0709</option>
178- <option data-type="xai" value="grok-2-vision-1212">grok-2-vision-1212</option>
179- <option data-type="xai" value="grok-vision-beta">grok-vision-beta</option>
180159 </select>
181160 </div>
182161 <div data-type="ollama">
public/scripts/extensions/connection-manager/index.js+22 -2
@@ -1,6 +1,6 @@
11import { DOMPurify, Fuse } from '../../../lib.js';
22
33import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
44import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
55import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
66import { SlashCommand } from '../../slash-commands/SlashCommand.js';
@@ -11,7 +11,7 @@ import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDe
1111import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
1212import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
1313import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
1414import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
1515import { t } from '../../i18n.js';
1616import { getSecretLabelById } from '../../secrets.js';
1717
@@ -167,6 +167,12 @@ const profilesProvider = () => [
167167 * @property {string} [stop-strings] Custom Stopping Strings
168168 * @property {string} [start-reply-with] Start Reply With
169169 * @property {string} [reasoning-template] Reasoning Template
170+ * @property {string} [prompt-post-processing] Prompt Post-Processing
171+ * @property {string} [sysprompt] System Prompt Name
172+ * @property {string} [sysprompt-state] Use System Prompt
173+ * @property {string} [api-url] Server URL
174+ * @property {string} [secret-id] Secret ID
175+ * @property {string} [regex-preset] Regex Preset ID
170176 * @property {string[]} [exclude] Commands to exclude
171177 */
172178
@@ -684,6 +690,13 @@ async function renderDetailsContent(detailsContent) {
684690 defaultValue: 'true',
685691 enumList: commonEnumProviders.boolean('trueFalse')(),
686692 }),
693+ SlashCommandNamedArgument.fromProps({
694+ name: 'timeout',
695+ description: 'Maximum time to wait for the API connection to be established, in milliseconds. Set to 0 to disable. Only applies when await=true.',
696+ isRequired: false,
697+ typeList: [ARGUMENT_TYPE.NUMBER],
698+ defaultValue: '2000',
699+ }),
687700 ],
688701 callback: async (args, value) => {
689702 if (!value || typeof value !== 'string') {
@@ -715,6 +728,13 @@ async function renderDetailsContent(detailsContent) {
715728
716729 if (shouldAwait) {
717730 await awaitPromise;
731+
732+ // We should also await the connection to be established
733+ const parsedTimeout = parseInt(args?.timeout?.toString());
734+ const timeout = !isNaN(parsedTimeout) ? Math.max(0, parsedTimeout) : 2000;
735+ if (timeout > 0) {
736+ await waitUntilCondition(() => online_status !== 'no_connection', timeout, 100, { rejectOnTimeout: false });
737+ }
718738 }
719739
720740 return profile.name;
public/scripts/extensions/memory/index.js+1 -1
@@ -993,7 +993,7 @@ function doPopout(e) {
993993 originalElement.empty();
994994 originalElement.html('<div class="flex-container alignitemscenter justifyCenter wide100p"><small>Currently popped out</small></div>');
995995 newElement.append(controlBarHtml).append(originalHTMLClone);
996996 $('body#movingDivs').append(newElement);
997997 newElement.transition({ opacity: 1, duration: animation_duration, easing: animation_easing });
998998 $('#summaryExtensionDrawerContents').addClass('scrollableInnerFull');
999999 setMemoryContext(prevSummaryBoxContents, false); //paste prev summary box contents into popout box
public/scripts/extensions/regex/debugger.html+5 -1
@@ -11,6 +11,7 @@
1111 <button
1212 id="regex_debugger_save_order"
1313 class="menu_button menu_button_icon interactable"
14+ data-i18n="[title]ext_regex_debugger_save_order_help"
1415 title="Save current rule order"
1516 tabindex="0"
1617 >
@@ -53,6 +54,7 @@
5354 <button
5455 id="regex_debugger_run_test"
5556 class="menu_button menu_button_icon interactable"
57+ data-i18n="[title]ext_regex_debugger_run_test_help"
5658 title="Run the test pipeline"
5759 tabindex="0"
5860 >
@@ -115,6 +117,7 @@
115117 <div
116118 id="regex_debugger_expand_steps"
117119 class="menu_button menu_button_icon"
120+ data-i18n="[title]Expand view"
118121 title="Expand view"
119122 >
120123 <i class="fa-solid fa-expand"></i>
@@ -132,6 +135,7 @@
132135 <div
133136 id="regex_debugger_expand_final"
134137 class="menu_button menu_button_icon"
138+ data-i18n="[title]Expand view"
135139 title="Expand view"
136140 >
137141 <i class="fa-solid fa-expand"></i>
@@ -158,7 +162,7 @@
158162 <code class="rule-regex"></code>
159163 <small class="rule-scope"></small>
160164 </div>
161165 <div class="menu_button menu_button_icon edit_rule" data-i18n="[title]Edit Rule" title="Edit Rule">
162166 <i class="fa-solid fa-pencil"></i>
163167 </div>
164168 </li>
public/scripts/extensions/regex/dropdown.html+35 -3
@@ -16,6 +16,10 @@
1616 <i class="fa-solid fa-address-card"></i>
1717 <small data-i18n="ext_regex_new_scoped_script">+ Scoped</small>
1818 </div>
19+ <div id="open_preset_editor" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_new_preset_script_desc" title="New preset regex script">
20+ <i class="fa-solid fa-sliders"></i>
21+ <small data-i18n="ext_regex_new_preset_script">+ Preset</small>
22+ </div>
1923 <div id="import_regex" class="menu_button menu_button_icon">
2024 <i class="fa-solid fa-file-import"></i>
2125 <small data-i18n="ext_regex_import_script">Import</small>
@@ -31,7 +35,8 @@
3135 <small data-i18n="ext_regex_debugger">Debugger</small>
3236 </div>
3337 </div>
3438 <divhr class="regex_bulk_operations flex-container justifyCenterregex_bulk_operations_hr" />
39+ <div class="regex_bulk_operations flex-container">
3540 <div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">
3641 <i class="fa-solid fa-check-double"></i>
3742 </div>
@@ -43,6 +48,18 @@
4348 <i class="fa-solid fa-toggle-off"></i>
4449 <small data-i18n="Disable">Disable</small>
4550 </div>
51+ <div id="bulk_regex_move_to_global" class="menu_button menu_button_icon" hidden>
52+ <i class="fa-solid fa-globe"></i>
53+ <small data-i18n="ext_regex_move_to_global">Move to global scripts</small>
54+ </div>
55+ <div id="bulk_regex_move_to_scoped" class="menu_button menu_button_icon" hidden>
56+ <i class="fa-solid fa-address-card"></i>
57+ <small data-i18n="ext_regex_move_to_scoped">Move to scoped scripts</small>
58+ </div>
59+ <div id="bulk_regex_move_to_preset" class="menu_button menu_button_icon" hidden>
60+ <i class="fa-solid fa-sliders"></i>
61+ <small data-i18n="ext_regex_move_to_preset">Move to preset scripts</small>
62+ </div>
4663 <div id="bulk_export_regex" class="menu_button menu_button_icon">
4764 <i class="fa-solid fa-file-export"></i>
4865 <small data-i18n="Export">Export</small>
@@ -69,7 +86,7 @@
6986 </div>
7087 </div>
7188 <hr />
7289 <div id="global_scripts_block" class="padding5">
7390 <div>
7491 <strong data-i18n="ext_regex_global_scripts">Global Scripts</strong>
7592 </div>
@@ -79,7 +96,7 @@
7996 <div id="saved_regex_scripts" no-scripts-text="No scripts found" data-i18n="[no-scripts-text]No scripts found" class="flex-container regex-script-container flexFlowColumn"></div>
8097 </div>
8198 <hr />
8299 <div id="scoped_scripts_block" class="padding5">
83100 <div class="flex-container alignItemsBaseline">
84101 <strong class="flex1" data-i18n="ext_regex_scoped_scripts">Scoped Scripts</strong>
85102 <label id="toggle_scoped_regex" class="checkbox flex-container" for="regex_scoped_toggle">
@@ -93,6 +110,21 @@
93110 </small>
94111 <div id="saved_scoped_scripts" no-scripts-text="No scripts found" data-i18n="[no-scripts-text]No scripts found" class="flex-container regex-script-container flexFlowColumn"></div>
95112 </div>
113+ <hr />
114+ <div id="preset_scripts_block">
115+ <div class="flex-container alignItemsBaseline">
116+ <strong class="flex1" data-i18n="ext_regex_preset_scripts">Preset Scripts</strong>
117+ <label id="toggle_preset_regex" class="checkbox flex-container" for="regex_preset_toggle">
118+ <input type="checkbox" id="regex_preset_toggle" class="enable_scoped" />
119+ <span class="regex-toggle-on fa-solid fa-toggle-on fa-lg" data-i18n="[title]ext_regex_disallow_preset" title="Disallow using preset regex"></span>
120+ <span class="regex-toggle-off fa-solid fa-toggle-off fa-lg" data-i18n="[title]ext_regex_allow_preset" title="Allow using preset regex"></span>
121+ </label>
122+ </div>
123+ <small data-i18n="ext_regex_preset_scripts_desc">
124+ Only available for this preset. Saved to the preset data.
125+ </small>
126+ <div id="saved_preset_scripts" class="flex-container regex-script-container flexFlowColumn"></div>
127+ </div>
96128 </div>
97129 </div>
98130</div>
public/scripts/extensions/regex/engine.js+227 -27
@@ -1,16 +1,228 @@
11import { characters, saveSettingsDebounced, substituteParams, substituteParamsExtended, this_chid } from '../../../script.js';
22import { extension_settings, writeExtensionField } from '../../extensions.js';
3+import { getPresetManager } from '../../preset-manager.js';
34import { regexFromString } from '../../utils.js';
4-export {
5+import { lodash } from '../../../lib.js';
5- regex_placement,
6+
6- getRegexedString,
7+/**
7- runRegexScript,
8+ * @enum {number} Regex scripts types
9+ * @readonly
10+ */
11+export const SCRIPT_TYPES = {
12+ GLOBAL: 0,
13+ SCOPED: 1,
14+ PRESET: 2,
815};
916
1017/**
18+ * Special type for unknown/invalid script types.
19+ */
20+export const SCRIPT_TYPE_UNKNOWN = -1;
21+
22+/**
23+ * @typedef {import('../../char-data.js').RegexScriptData} RegexScript
24+ */
25+
26+/**
27+ * @typedef {object} GetRegexScriptsOptions
28+ * @property {boolean} allowedOnly Only return allowed scripts
29+ */
30+
31+/**
32+ * @type {Readonly<GetRegexScriptsOptions>}
33+ */
34+const DEFAULT_GET_REGEX_SCRIPTS_OPTIONS = Object.freeze({ allowedOnly: false });
35+
36+/**
37+ * Retrieves the list of regex scripts by combining the scripts from the extension settings and the character data
38+ *
39+ * @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts
40+ * @returns {RegexScript[]} An array of regex scripts, where each script is an object containing the necessary information.
41+ */
42+export function getRegexScripts(options = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) {
43+ return [...Object.values(SCRIPT_TYPES).flatMap(type => getScriptsByType(type, options))];
44+}
45+
46+/**
47+ * Retrieves the regex scripts for a specific type.
48+ * @param {SCRIPT_TYPES} scriptType The type of regex scripts to retrieve.
49+ * @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts
50+ * @returns {RegexScript[]} An array of regex scripts for the specified type.
51+ */
52+export function getScriptsByType(scriptType, { allowedOnly } = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) {
53+ switch (scriptType) {
54+ case SCRIPT_TYPE_UNKNOWN:
55+ return [];
56+ case SCRIPT_TYPES.GLOBAL:
57+ return extension_settings.regex ?? [];
58+ case SCRIPT_TYPES.SCOPED: {
59+ if (allowedOnly && !extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar)) {
60+ return [];
61+ }
62+ const scopedScripts = characters[this_chid]?.data?.extensions?.regex_scripts;
63+ return Array.isArray(scopedScripts) ? scopedScripts : [];
64+ }
65+ case SCRIPT_TYPES.PRESET: {
66+ if (allowedOnly && !extension_settings?.preset_allowed_regex?.[getCurrentPresetAPI()]?.includes(getCurrentPresetName())) {
67+ return [];
68+ }
69+ const presetManager = getPresetManager();
70+ const presetScripts = presetManager?.readPresetExtensionField({ path: 'regex_scripts' });
71+ return Array.isArray(presetScripts) ? presetScripts : [];
72+ }
73+ default:
74+ console.warn(`getScriptsByType: Invalid script type ${scriptType}`);
75+ return [];
76+ }
77+}
78+
79+/**
80+ * Saves an array of regex scripts for a specific type.
81+ * @param {RegexScript[]} scripts An array of regex scripts to save.
82+ * @param {SCRIPT_TYPES} scriptType The type of regex scripts to save.
83+ * @returns {Promise<void>}
84+ */
85+export async function saveScriptsByType(scripts, scriptType) {
86+ switch (scriptType) {
87+ case SCRIPT_TYPES.GLOBAL:
88+ extension_settings.regex = scripts;
89+ saveSettingsDebounced();
90+ break;
91+ case SCRIPT_TYPES.SCOPED:
92+ await writeExtensionField(this_chid, 'regex_scripts', scripts);
93+ break;
94+ case SCRIPT_TYPES.PRESET: {
95+ const presetManager = getPresetManager();
96+ await presetManager.writePresetExtensionField({ path: 'regex_scripts', value: scripts });
97+ break;
98+ }
99+ default:
100+ console.warn(`saveScriptsByType: Invalid script type ${scriptType}`);
101+ break;
102+ }
103+}
104+
105+/**
106+ * Check if character's regexes are allowed to be used; if character is undefined, returns false
107+ * @param {import('../../char-data.js').v1CharData|undefined} character
108+ * @returns {boolean}
109+ */
110+export function isScopedScriptsAllowed(character) {
111+ return !!extension_settings?.character_allowed_regex?.includes(character?.avatar);
112+}
113+
114+/**
115+ * Allow character's regexes to be used; if character is undefined, do nothing
116+ * @param {import('../../char-data.js').v1CharData|undefined} character
117+ * @returns {void}
118+ */
119+export function allowScopedScripts(character) {
120+ const avatar = character?.avatar;
121+ if (!avatar) {
122+ return;
123+ }
124+ if (!Array.isArray(extension_settings?.character_allowed_regex)) {
125+ extension_settings.character_allowed_regex = [];
126+ }
127+ if (!extension_settings.character_allowed_regex.includes(avatar)) {
128+ extension_settings.character_allowed_regex.push(avatar);
129+ saveSettingsDebounced();
130+ }
131+}
132+
133+/**
134+ * Disallow character's regexes to be used; if character is undefined, do nothing
135+ * @param {import('../../char-data.js').v1CharData|undefined} character
136+ * @returns {void}
137+ */
138+export function disallowScopedScripts(character) {
139+ const avatar = character?.avatar;
140+ if (!avatar) {
141+ return;
142+ }
143+ if (!Array.isArray(extension_settings?.character_allowed_regex)) {
144+ return;
145+ }
146+ const index = extension_settings.character_allowed_regex.indexOf(avatar);
147+ if (index !== -1) {
148+ extension_settings.character_allowed_regex.splice(index, 1);
149+ saveSettingsDebounced();
150+ }
151+}
152+
153+/**
154+ * Check if preset's regexes are allowed to be used
155+ * @param {string} apiId API ID
156+ * @param {string} presetName Preset name
157+ * @returns {boolean} True if allowed, false if not
158+ */
159+export function isPresetScriptsAllowed(apiId, presetName) {
160+ if (!apiId || !presetName) {
161+ return false;
162+ }
163+ return !!extension_settings?.preset_allowed_regex?.[apiId]?.includes(presetName);
164+}
165+
166+/**
167+ * Allow preset's regexes to be used
168+ * @param {string} apiId API ID
169+ * @param {string} presetName Preset name
170+ * @returns {void}
171+ */
172+export function allowPresetScripts(apiId, presetName) {
173+ if (!apiId || !presetName) {
174+ return;
175+ }
176+ if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) {
177+ lodash.set(extension_settings, ['preset_allowed_regex', apiId], []);
178+ }
179+ if (!extension_settings.preset_allowed_regex[apiId].includes(presetName)) {
180+ extension_settings.preset_allowed_regex[apiId].push(presetName);
181+ saveSettingsDebounced();
182+ }
183+}
184+
185+/**
186+ * Disallow preset's regexes to be used
187+ * @param {string} apiId API ID
188+ * @param {string} presetName Preset name
189+ * @returns {void}
190+ */
191+export function disallowPresetScripts(apiId, presetName) {
192+ if (!apiId || !presetName) {
193+ return;
194+ }
195+ if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) {
196+ return;
197+ }
198+ const index = extension_settings.preset_allowed_regex[apiId].indexOf(presetName);
199+ if (index !== -1) {
200+ extension_settings.preset_allowed_regex[apiId].splice(index, 1);
201+ saveSettingsDebounced();
202+ }
203+}
204+
205+/**
206+ * Gets the current API ID from the preset manager.
207+ * @returns {string|null} Current API ID, or null if no preset manager
208+ */
209+export function getCurrentPresetAPI() {
210+ return getPresetManager()?.apiId ?? null;
211+}
212+
213+/**
214+ * Gets the name of the currently selected preset.
215+ * @returns {string|null} The name of the currently selected preset, or null if no preset manager
216+ */
217+export function getCurrentPresetName() {
218+ return getPresetManager()?.getSelectedPresetName() ?? null;
219+}
220+
221+/**
11222 * @enum {number} Where the regex script should be applied
223+ * @readonly
12224 */
13225export const regex_placement = {
14226 /**
15227 * @deprecated MD Display is deprecated. Do not use.
16228 */
@@ -23,6 +235,10 @@ const regex_placement = {
23235 REASONING: 6,
24236};
25237
238+/**
239+ * @enum {number} How to substitute parameters in the find regex
240+ * @readonly
241+ */
26242export const substitute_find_regex = {
27243 NONE: 0,
28244 RAW: 1,
@@ -51,22 +267,6 @@ function sanitizeRegexMacro(x) {
51267 }) : x;
52268}
53269
54-function getScopedRegex() {
55- const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar);
56-
57- if (!isAllowed) {
58- return [];
59- }
60-
61- const scripts = characters[this_chid]?.data?.extensions?.regex_scripts;
62-
63- if (!Array.isArray(scripts)) {
64- return [];
65- }
66-
67- return scripts;
68-}
69-
70270/**
71271 * Parent function to fetch a regexed version of a raw string
72272 * @param {string} rawString The raw string to be regexed
@@ -75,7 +275,7 @@ function getScopedRegex() {
75275 * @returns {string} The regexed string
76276 * @typedef {{characterOverride?: string, isMarkdown?: boolean, isPrompt?: boolean, isEdit?: boolean, depth?: number }} RegexParams The parameters to use for the regex script
77277 */
78278export function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt, isEdit, depth } = {}) {
79279 // WTF have you passed me?
80280 if (typeof rawString !== 'string') {
81281 console.warn('getRegexedString: rawString is not a string. Returning empty string.');
@@ -87,7 +287,7 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown,
87287 return finalString;
88288 }
89289
90- const allRegex = [...(extension_settings.regex ?? []), ...(getScopedRegex() ?? [])];
290+ const allRegex = getRegexScripts({ allowedOnly: true });
91291 allRegex.forEach((script) => {
92292 if (
93293 // Script applies to Markdown and input is Markdown
@@ -126,13 +326,13 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown,
126326
127327/**
128328 * Runs the provided regex script on the given string
129329 * @param {import('./index.js').RegexScript} regexScript The regex script to run
130330 * @param {string} rawString The string to run the regex script on
131331 * @param {RegexScriptParams} params The parameters to use for the regex script
132332 * @returns {string} The new string
133333 * @typedef {{characterOverride?: string}} RegexScriptParams The parameters to use for the regex script
134334 */
135335export function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
136336 let newString = rawString;
137337 if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) {
138338 return newString;
public/scripts/extensions/regex/importTarget.html+6 -0
@@ -15,5 +15,11 @@
1515 Scoped Scripts
1616 </span>
1717 </label>
18+ <label for="regex_import_target_preset">
19+ <input type="radio" name="regex_import_target" id="regex_import_target_preset" value="preset" />
20+ <span data-i18n="ext_regex_preset_scripts">
21+ Preset Scripts
22+ </span>
23+ </label>
1824 </div>
1925</div>
public/scripts/extensions/regex/index.js+526 -168
@@ -1,5 +1,5 @@
11import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
22import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js';
33import { selected_group } from '../../group-chats.js';
44import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
55import { SlashCommand } from '../../slash-commands/SlashCommand.js';
@@ -7,10 +7,14 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
77import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
88import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
99import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
1010import { download, equalsIgnoreCaseAndAccents, escapeHtml, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4, escapeHtml } from '../../utils.js';
1111import { allowPresetScripts, allowScopedScripts, disallowPresetScripts, disallowScopedScripts, getCurrentPresetAPI, getCurrentPresetName, getRegexScripts, getScriptsByType, isPresetScriptsAllowed, isScopedScriptsAllowed, regex_placement, runRegexScript, saveScriptsByType, SCRIPT_TYPE_UNKNOWN, SCRIPT_TYPES, substitute_find_regex } from './engine.js';
1212import { t } from '../../i18n.js';
1313import { accountStorage } from '../../util/AccountStorage.js';
14+import { getPresetManager } from '../../preset-manager.js';
15+
16+// Re-exports for legacy extensions
17+export { getRegexScripts };
1418
1519const sanitizeFileName = name => name.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase();
1620
@@ -30,12 +34,14 @@ const sanitizeFileName = name => name.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '
3034 * @property {boolean} isSelected - Whether the preset is currently selected
3135 * @property {RegexPresetItem[]} global - The list of global preset items
3236 * @property {RegexPresetItem[]} scoped - The list of scoped preset items
37+ * @property {RegexPresetItem[]} preset - The list of preset preset items
3338 */
3439
3540/**
3641 * @typedef {object} RegexPresetState
3742 * @property {string[]} global - List of enabled global regex script IDs
3843 * @property {string[]} scoped - List of enabled scoped regex script IDs
44+ * @property {string[]} preset - List of enabled preset regex script IDs
3945 */
4046
4147class RegexPresetManager {
@@ -65,12 +71,14 @@ class RegexPresetManager {
6571 * @returns {RegexPresetState} The current state object
6672 */
6773 captureCurrentState() {
6874 const globalScripts = this.regexListToPresetItems(extension_settingsgetScriptsByType(SCRIPT_TYPES.regexGLOBAL) || []);
6975 const scopedScripts = this.regexListToPresetItems(characters[this_chid]?.data?.extensions?getScriptsByType(SCRIPT_TYPES.regex_scriptsSCOPED) || []);
76+ const presetScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET));
7077
7178 return {
7279 global: globalScripts.map(item => item.id).sort(),
7380 scoped: scopedScripts.map(item => item.id).sort(),
81+ preset: presetScripts.map(item => item.id).sort(),
7482 };
7583 }
7684
@@ -87,13 +95,16 @@ class RegexPresetManager {
8795 const global2 = state2.global || [];
8896 const scoped1 = state1.scoped || [];
8997 const scoped2 = state2.scoped || [];
98+ const preset1 = state1.preset || [];
99+ const preset2 = state2.preset || [];
90100
91101 if (global1.length !== global2.length || scoped1.length !== scoped2.length) {
92102 return true;
93103 }
94104
95105 return !global1.every(id => global2.includes(id)) ||
96106 !scoped1.every(id => scoped2.includes(id)); ||
107+ !preset1.every(id => preset2.includes(id));
97108 }
98109
99110 /**
@@ -362,17 +373,18 @@ class RegexPresetManager {
362373 return;
363374 }
364375
365376 // Apply to both globalpreset andto scopedall lists
366- await this.applyPresetList({
377+ for (const scriptType of Object.values(SCRIPT_TYPES)) {
367- presetList: preset.global,
378+ await this.applyPresetList({
368- targetList: extension_settings.regex,
379+ presetList: {
369- saveFunction: () => saveSettingsDebounced(),
380+ [SCRIPT_TYPES.GLOBAL]: preset.global,
370- });
381+ [SCRIPT_TYPES.SCOPED]: preset.scoped,
371- await this.applyPresetList({
382+ [SCRIPT_TYPES.PRESET]: preset.preset,
372- presetList: preset.scoped,
383+ }[scriptType],
373- targetList: characters[this_chid]?.data?.extensions?.regex_scripts,
384+ targetList: getScriptsByType(scriptType),
374385 saveFunction: (scripts) => writeExtensionFieldsaveScriptsByType(this_chid, 'regex_scripts'scripts, scriptsscriptType),
375386 });
387+ }
376388
377389 // Render the changes to the UI
378390 await loadRegexScripts();
@@ -418,8 +430,9 @@ class RegexPresetManager {
418430 id: id,
419431 name: name,
420432 isSelected: false,
421433 global: this.regexListToPresetItems(extension_settingsgetScriptsByType(SCRIPT_TYPES.regexGLOBAL)),
422434 scoped: this.regexListToPresetItems(characters[this_chid]?.data?.extensions?getScriptsByType(SCRIPT_TYPES.regex_scriptsSCOPED)),
435+ preset: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET)),
423436 };
424437
425438 if (isUpdate) {
@@ -465,15 +478,6 @@ class RegexPresetManager {
465478const presetManager = new RegexPresetManager();
466479
467480/**
468- * Retrieves the list of regex scripts by combining the scripts from the extension settings and the character data
469- *
470- * @return {RegexScript[]} An array of regex scripts, where each script is an object containing the necessary information.
471- */
472-export function getRegexScripts() {
473- return [...(extension_settings.regex ?? []), ...(characters[this_chid]?.data?.extensions?.regex_scripts ?? [])];
474-}
475-
476-/**
477481 * Toggle the icon for the "select all" checkbox in the regex settings.
478482 * - Use `fa-check-double` when the checkbox is unchecked (indicating all scripts are not selected).
479483 * - Use `fa-minus` when the checkbox is checked (indicating all scripts are selected).
@@ -486,15 +490,28 @@ function setToggleAllIcon(allAreChecked) {
486490}
487491
488492/**
493+ * Sets the visibility of the bulk move buttons based on selected scripts.
494+ */
495+function setMoveButtonsVisibility() {
496+ const hasGlobalScripts = $('#saved_regex_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0;
497+ const hasScopedScripts = $('#saved_scoped_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0;
498+ const hasPresetScripts = $('#saved_preset_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0;
499+ $('#bulk_regex_move_to_global').toggle(hasScopedScripts || hasPresetScripts);
500+ $('#bulk_regex_move_to_scoped').toggle(hasGlobalScripts || hasPresetScripts);
501+ $('#bulk_regex_move_to_preset').toggle(hasGlobalScripts || hasScopedScripts);
502+}
503+
504+/**
489505 * Saves a regex script to the extension settings or character data.
490506 * @param {import('../../char-data.js').RegexScriptData} regexScript
491507 * @param {number} existingScriptIndex Index of the existing script
492508 * @param {booleanSCRIPT_TYPES} isScopedscriptType IsType of the script scoped to a character?
509+ * @param {boolean} [saveSettings=true] Whether to save the settings immediately
493510 * @returns {Promise<void>}
494511 */
495512async function saveRegexScript(regexScript, existingScriptIndex, isScopedscriptType, saveSettings = true) {
496513 // If not editing
497- const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
514+ const array = getScriptsByType(scriptType);
498515
499516 // Assign a UUID if it doesn't exist
500517 if (!regexScript.id) {
@@ -523,22 +540,25 @@ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
523540 array.push(regexScript);
524541 }
525542
526543 if (isScopedscriptType === SCRIPT_TYPES.SCOPED) {
527544 await writeExtensionFieldsaveScriptsByType(this_chid, 'regex_scripts'array, arraySCRIPT_TYPES.SCOPED);
545+ allowScopedScripts(characters?.[this_chid]);
546+ }
528547
529- // Add the character to the allowed list
548+ if (scriptType === SCRIPT_TYPES.PRESET) {
530- if (!extension_settings.character_allowed_regex.includes(characters[this_chid].avatar)) {
549+ await saveScriptsByType(array, SCRIPT_TYPES.PRESET);
531- extension_settings.character_allowed_regex.push(characters[this_chid].avatar);
550+ allowPresetScripts(getCurrentPresetAPI(), getCurrentPresetName());
532- }
533551 }
534552
535- saveSettingsDebounced();
553+ if (saveSettings) {
536554 await loadRegexScripts saveSettingsDebounced();
555+ await loadRegexScripts();
537556
538557 // Reload the current chat to undo previous markdown
539558 const currentChatId = getCurrentChatId();
540- if (currentChatId !== undefined && currentChatId !== null) {
559+ if (currentChatId) {
541560 await reloadCurrentChat();
561+ }
542562 }
543563
544564 const debuggerPopup = $('#regex_debugger_popup');
@@ -547,25 +567,67 @@ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
547567 }
548568}
549569
550-async function deleteRegexScript({ id, isScoped }) {
570+/**
551- const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
571+ * Delete a regex script by ID
572+ * @param {string} id ID of the script to delete
573+ * @param {SCRIPT_TYPES} scriptType Type of the script
574+ * @param {boolean} saveSettings Whether to save the settings immediately
575+ * @returns {Promise<void>}
576+ */
577+async function deleteRegexScript(id, scriptType, saveSettings = true) {
578+ const array = getScriptsByType(scriptType);
552579
553580 const existingScriptIndex = array.findIndex((script) => script.id === id);
554581 if (existingScriptIndex !== -1) {
555582 array.splice(existingScriptIndex, 1);
556583
557584 ifswitch (isScopedscriptType) {
558- await writeExtensionField(this_chid, 'regex_scripts', array);
585+ case SCRIPT_TYPES.GLOBAL:
586+ // will be handled by saveSettingsDebounced
587+ break;
588+ case SCRIPT_TYPES.SCOPED:
589+ await saveScriptsByType(array, SCRIPT_TYPES.SCOPED);
590+ break;
591+ case SCRIPT_TYPES.PRESET:
592+ await saveScriptsByType(array, SCRIPT_TYPES.PRESET);
593+ break;
594+ default:
595+ break;
596+ }
597+ if (saveSettings) {
598+ saveSettingsDebounced();
599+ await loadRegexScripts();
559600 }
601+ }
602+}
560603
561- saveSettingsDebounced();
604+/**
562- await loadRegexScripts();
605+ * Move a regex script from one type to another
606+ * @param {import('../../char-data.js').RegexScriptData} script The script to move
607+ * @param {SCRIPT_TYPES} toType Target type
608+ * @param {SCRIPT_TYPES|null} fromType Source type, if null it will be determined automatically
609+ * @param {boolean} saveSettings Whether to save the settings immediately
610+ * @returns {Promise<void>}
611+ */
612+async function moveRegexScript(script, toType, fromType = null, saveSettings = true) {
613+ if (!Object.values(SCRIPT_TYPES).includes(toType)) {
614+ console.warn(`moveRegexScript: Invalid target script type ${toType}`);
615+ return;
616+ }
617+ if (!Object.values(SCRIPT_TYPES).includes(fromType)) {
618+ fromType = getScriptType(script);
563619 }
620+ if (fromType === toType || fromType === SCRIPT_TYPE_UNKNOWN || toType === SCRIPT_TYPE_UNKNOWN) {
621+ return;
622+ }
623+ await deleteRegexScript(script.id, fromType, false);
624+ await saveRegexScript(script, -1, toType, saveSettings);
564625}
565626
566627async function loadRegexScripts() {
567628 $('#saved_regex_scripts').empty();
568629 $('#saved_scoped_scripts').empty();
630+ $('#saved_preset_scripts').empty();
569631 setToggleAllIcon(false);
570632
571633 const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate'));
@@ -574,20 +636,20 @@ async function loadRegexScripts() {
574636 * Renders a script to the UI.
575637 * @param {string} container Container to render the script to
576638 * @param {import('../../char-data.js').RegexScriptData} script Script data
577639 * @param {booleanSCRIPT_TYPES} isScoped Script isscriptType scopedType toof athe characterscript
578640 * @param {number} index Index of the script in the array
579641 */
580642 function renderScript(container, script, isScopedscriptType, index) {
581643 // Have to clone here
582644 const scriptHtml = scriptTemplate.clone();
583645 const save = () => saveRegexScript(script, index, isScopedscriptType);
584646
585647 if (!script.id) {
586648 script.id = uuidv4();
587649 }
588650
589651 scriptHtml.attr('id', script.id);
590652 scriptHtml.find('.regex_script_name').text(script.scriptName).attr('title', script.scriptName);
591653 scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false)
592654 .on('input', async function () {
593655 script.disabled = !!$(this).prop('checked');
@@ -600,7 +662,7 @@ async function loadRegexScripts() {
600662 scriptHtml.find('.disable_regex').prop('checked', false).trigger('input');
601663 });
602664 scriptHtml.find('.edit_existing_regex').on('click', async function () {
603665 await onRegexEditorOpenClick(scriptHtml.attr('id'), isScopedscriptType);
604666 });
605667 scriptHtml.find('.move_to_global').on('click', async function () {
606668 const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to global?`, POPUP_TYPE.CONFIRM);
@@ -608,29 +670,32 @@ async function loadRegexScripts() {
608670 if (!confirm) {
609671 return;
610672 }
611-
673+ await moveRegexScript(script, SCRIPT_TYPES.GLOBAL, scriptType);
612- await deleteRegexScript({ id: script.id, isScoped: true });
613- await saveRegexScript(script, -1, false);
614674 });
615675 scriptHtml.find('.move_to_scoped').on('click', async function () {
616676 if (this_chid === undefined) {
617677 toastr.error(t`No character selected.`);
618678 return;
619679 }
620-
621680 if (selected_group) {
622681 toastr.error(t`Cannot edit scoped scripts in group chats.`);
623682 return;
624683 }
625-
626684 const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to scoped?`, POPUP_TYPE.CONFIRM);
627-
628685 if (!confirm) {
629686 return;
630687 }
631-
688+ await moveRegexScript(script, SCRIPT_TYPES.SCOPED, scriptType);
632- await deleteRegexScript({ id: script.id, isScoped: false });
689+ });
633- await saveRegexScript(script, -1, true);
690+ scriptHtml.find('.move_to_preset').on('click', async function () {
691+ const confirm = await callGenericPopup(
692+ t`Are you sure you want to move this regex script to preset?`,
693+ POPUP_TYPE.CONFIRM,
694+ );
695+ if (!confirm) {
696+ return;
697+ }
698+ await moveRegexScript(script, SCRIPT_TYPES.PRESET, scriptType);
634699 });
635700 scriptHtml.find('.export_regex').on('click', async function () {
636701 const fileName = `regex-${sanitizeFileName(script.scriptName)}.json`;
@@ -639,39 +704,65 @@ async function loadRegexScripts() {
639704 });
640705 scriptHtml.find('.delete_regex').on('click', async function () {
641706 const confirm = await callGenericPopup(t`Are you sure you want to delete this regex script?`, POPUP_TYPE.CONFIRM);
642-
643707 if (!confirm) {
644708 return;
645709 }
646-
710+ await deleteRegexScript(script.id, scriptType);
647- await deleteRegexScript({ id: script.id, isScoped });
648711 await reloadCurrentChat();
649712 });
650713 scriptHtml.find('.regex_bulk_checkbox').on('change', function () {
714+ setMoveButtonsVisibility();
651715 const checkboxes = $('#regex_container .regex_bulk_checkbox');
652716 const allAreChecked = checkboxes.length === checkboxes.filter(':checked').length;
653717 setToggleAllIcon(allAreChecked);
654718 });
719+ scriptHtml.find('input[name="regex_expand"]').on('change', function () {
720+ if (!(this instanceof HTMLInputElement)) {
721+ return;
722+ }
723+
724+ if (!this.checked) {
725+ return;
726+ }
727+
728+ const closeMenuHandler = (e) => {
729+ if (e.target instanceof HTMLElement) {
730+ if (e.target.closest('.regex-script-label')) {
731+ return;
732+ }
733+ this.checked = false;
734+ document.removeEventListener('click', closeMenuHandler);
735+ }
736+ };
737+
738+ // Use setTimeout to avoid closing immediately from the same click
739+ setTimeout(() => {
740+ document.addEventListener('click', closeMenuHandler, { passive: true, once: false });
741+ }, 0);
742+ });
655743
656744 $(container).append(scriptHtml);
657745 }
658746
659747 extension_settings?getScriptsByType(SCRIPT_TYPES.regex?GLOBAL).forEach((script, index) => renderScript('#saved_regex_scripts', script, falseSCRIPT_TYPES.GLOBAL, index));
660748 characters[this_chid]?.data?.extensions?getScriptsByType(SCRIPT_TYPES.regex_scripts?SCOPED).forEach((script, index) => renderScript('#saved_scoped_scripts', script, trueSCRIPT_TYPES.SCOPED, index));
749+ getScriptsByType(SCRIPT_TYPES.PRESET).forEach((script, index) => renderScript('#saved_preset_scripts', script, SCRIPT_TYPES.PRESET, index));
661750
662- const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar);
751+ $('#regex_scoped_toggle').prop('checked', isScopedScriptsAllowed(characters?.[this_chid]));
663752 $('#regex_scoped_toggleregex_preset_toggle').prop('checked', isAllowedisPresetScriptsAllowed(getCurrentPresetAPI(), getCurrentPresetName()));
753+
754+ setMoveButtonsVisibility();
664755}
665756
666757/**
667758 * Opens the regex editor.
668759 * @param {string|boolean} existingId Existing ID
669760 * @param {booleanSCRIPT_TYPES} isScopedscriptType IsType of the script scoped to a character?
670761 * @returns {Promise<void>}
671762 */
672763async function onRegexEditorOpenClick(existingId, isScopedscriptType) {
673764 const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor'));
674- const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
765+ const array = getScriptsByType(scriptType);
675766
676767 // If an ID exists, fill in all the values
677768 let existingScriptIndex = -1;
@@ -776,7 +867,7 @@ async function onRegexEditorOpenClick(existingId, isScoped) {
776867 maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())),
777868 };
778869
779870 saveRegexScript(newRegexScript, existingScriptIndex, isScopedscriptType);
780871 }
781872}
782873
@@ -969,28 +1060,35 @@ function populateDebuggerRuleList(container) {
9691060
9701061 const allScripts = getRegexScripts();
9711062 if (!allScripts || allScripts.length === 0) {
9721063 rulesContainer.append('<div class="regex-debugger-no-rules">' + t`No regex rules found.` + '</div>');
9731064 return;
9741065 }
9751066
9761067 const globalScriptIds = new Set(getScriptsByType(extension_settingsSCRIPT_TYPES.regex ?? []GLOBAL).map(s => s.id));
1068+ const scopedScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.SCOPED).map(s => s.id));
1069+ const presetScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.PRESET).map(s => s.id));
9771070 const globalScripts = [];
9781071 const scopedScripts = [];
1072+ const presetScripts = [];
9791073
9801074 allScripts.forEach(script => {
9811075 const scriptCopy = structuredClone(script); // Use structuredClone for deep copy
9821076 if (globalScriptIds.has(script.id)) {
9831077 // @ts-ignore
9841078 scriptCopy.isScopedtype = falseSCRIPT_TYPES.GLOBAL;
9851079 globalScripts.push(scriptCopy);
986- } else {
1080+ } else if (scopedScriptIds.has(script.id)) {
9871081 // @ts-ignore
9881082 scriptCopy.isScopedtype = trueSCRIPT_TYPES.SCOPED;
9891083 scopedScripts.push(scriptCopy);
1084+ } else if (presetScriptIds.has(script.id)) {
1085+ // @ts-ignore
1086+ scriptCopy.type = SCRIPT_TYPES.PRESET;
1087+ presetScripts.push(scriptCopy);
9901088 }
9911089 });
9921090
9931091 container.data('allScripts', [...globalScripts, ...scopedScripts, ...presetScripts]);
9941092
9951093 const renderRule = (script) => {
9961094 if (!script.id) script.id = uuidv4();
@@ -1002,10 +1100,18 @@ function populateDebuggerRuleList(container) {
10021100 ruleElement.find('.rule-name').text(script.scriptName);
10031101 ruleElement.find('.rule-regex').text(script.findRegex);
10041102 // @ts-ignore
1005- ruleElement.find('.rule-scope').text(script.isScoped ? 'Scoped' : 'Global');
1103+ ruleElement
1104+ .find('.rule-scope')
1105+ .text(
1106+ {
1107+ [SCRIPT_TYPES.SCOPED]: t`Scoped`,
1108+ [SCRIPT_TYPES.GLOBAL]: t`Global`,
1109+ [SCRIPT_TYPES.PRESET]: t`Preset`,
1110+ }[script.type],
1111+ );
10061112 ruleElement.find('.rule-enabled').prop('checked', !script.disabled);
10071113 // @ts-ignore
10081114 ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.isScopedtype));
10091115
10101116 ruleElement.on('click', function (event) {
10111117 if ($(event.target).is('input, .menu_button, .menu_button i')) {
@@ -1035,18 +1141,25 @@ function populateDebuggerRuleList(container) {
10351141 };
10361142
10371143 if (globalScripts.length > 0) {
10381144 rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Global Rules` + '</div>');
10391145 const globalList = $('<ul id="regex_debugger_rules_global" class="sortable-list"></ul>');
10401146 globalScripts.forEach(script => globalList.append(renderRule(script)));
10411147 rulesContainer.append(globalList);
10421148 }
10431149
10441150 if (scopedScripts.length > 0) {
10451151 rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Scoped Rules` + '</div>');
10461152 const scopedList = $('<ul id="regex_debugger_rules_scoped" class="sortable-list"></ul>');
10471153 scopedScripts.forEach(script => scopedList.append(renderRule(script)));
10481154 rulesContainer.append(scopedList);
10491155 }
1156+
1157+ if (presetScripts.length > 0) {
1158+ rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Preset Rules` + '</div>');
1159+ const presetList = $('<ul id="regex_debugger_rules_preset" class="sortable-list"></ul>');
1160+ presetScripts.forEach(script => presetList.append(renderRule(script)));
1161+ rulesContainer.append(presetList);
1162+ }
10501163}
10511164
10521165/**
@@ -1065,12 +1178,15 @@ async function onRegexDebuggerOpenClick() {
10651178 debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
10661179 // @ts-ignore
10671180 debuggerHtml.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
1181+ // @ts-ignore
1182+ debuggerHtml.find('#regex_debugger_rules_preset').sortable({ delay: getSortableDelay() }).disableSelection();
10681183
10691184 debuggerHtml.find('#regex_debugger_run_test').on('click', function () {
10701185 const allScripts = debuggerHtml.data('allScripts');
10711186 const orderedRuleIds = [
10721187 ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
10731188 ...$('#regex_debugger_rules_scoped').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
1189+ ...$('#regex_debugger_rules_preset').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
10741190 ];
10751191
10761192 const rawInput = String($('#regex_debugger_raw_input').val());
@@ -1106,9 +1222,9 @@ async function onRegexDebuggerOpenClick() {
11061222 // Set the ID on the TOP-LEVEL element that is being appended.
11071223 stepElement.find('>:first-child').attr('id', `step-result-${script.id}`);
11081224 const stepHeader = stepElement.find('.step-header');
11091225 stepHeader.find('strong').text(t`After:` + ` ${script.scriptName}`);
11101226
11111227 const metricsHtml = `'<span class="step-metrics">' + t`Captured:` + ` ${result.charsCaptured}, ` + t`Added:` + ` +${result.charsAdded}, ` + t`Removed:` + ` -${result.charsRemoved}</span>`;
11121228 stepHeader.append(metricsHtml);
11131229
11141230 if (displayMode === 'highlight') {
@@ -1128,7 +1244,7 @@ async function onRegexDebuggerOpenClick() {
11281244
11291245 const summaryHtml = `
11301246 <div id="regex_debugger_final_summary" class="regex-debugger-summary">
11311247 <strong>` + t`Total Captured:` + `</strong> ${totalCharsCaptured} | <strong>` + t`Total Added:` + `</strong> +${totalCharsAdded} | <strong>` + t`Total Removed:` + `</strong> -${totalCharsRemoved}
11321248 </div>
11331249 `;
11341250 finalOutput.before(summaryHtml);
@@ -1148,11 +1264,13 @@ async function onRegexDebuggerOpenClick() {
11481264 const allKnownScripts = getRegexScripts();
11491265 const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
11501266 const newScopedScripts = $('#regex_debugger_rules_scoped').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
1267+ const newPresetScripts = $('#regex_debugger_rules_preset').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
11511268
11521269 extension_settings.regex = newGlobalScripts;
11531270 if (this_chid !== undefined) {
11541271 await writeExtensionFieldsaveScriptsByType(this_chid, 'regex_scripts'newScopedScripts, newScopedScriptsSCRIPT_TYPES.SCOPED);
11551272 }
1273+ await saveScriptsByType(newPresetScripts, SCRIPT_TYPES.PRESET);
11561274
11571275 saveSettingsDebounced();
11581276 await loadRegexScripts();
@@ -1164,6 +1282,8 @@ async function onRegexDebuggerOpenClick() {
11641282 currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
11651283 // @ts-ignore
11661284 currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
1285+ // @ts-ignore
1286+ currentPopupContent.find('#regex_debugger_rules_preset').sortable({ delay: getSortableDelay() }).disableSelection();
11671287 });
11681288
11691289 debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () {
@@ -1204,13 +1324,13 @@ async function onRegexDebuggerOpenClick() {
12041324 });
12051325
12061326 popupContainer.append(navPanel).append(contentPanel);
12071327 callGenericPopup(popupContainer, POPUP_TYPE.TEXT, 't`Step-by-step Transformation'`, { wide: true, allowVerticalScrolling: false });
12081328 });
12091329
12101330 debuggerHtml.find('#regex_debugger_expand_final').on('click', function () {
12111331 const content = $('#regex_debugger_final_output').html();
12121332 const popupContent = $('<div class="regex-popup-content"></div>').html(content);
12131333 callGenericPopup(popupContent, POPUP_TYPE.TEXT, 't`Final Output'`, { wide: true, large: true, allowVerticalScrolling: true });
12141334 });
12151335
12161336 await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true });
@@ -1289,11 +1409,6 @@ function migrateSettings() {
12891409 }
12901410 });
12911411
1292- if (!extension_settings.character_allowed_regex) {
1293- extension_settings.character_allowed_regex = [];
1294- performSave = true;
1295- }
1296-
12971412 if (performSave) {
12981413 saveSettingsDebounced();
12991414 }
@@ -1364,10 +1479,10 @@ async function toggleRegexCallback(args, scriptName) {
13641479 break;
13651480 }
13661481
1367- const isScoped = characters[this_chid]?.data?.extensions?.regex_scripts?.some(s => s.id === script.id);
1482+ const scriptType = getScriptType(script);
13681483 const index = isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts?.indexOfgetScriptsByType(scriptscriptType) : scripts.indexOf(script);
13691484
13701485 await saveRegexScript(script, index, isScopedscriptType);
13711486 if (script.disabled) {
13721487 !quiet && toastr.success(t`Regex script '${scriptName}' has been disabled.`);
13731488 } else {
@@ -1379,10 +1494,10 @@ async function toggleRegexCallback(args, scriptName) {
13791494
13801495/**
13811496 * Performs the import of the regex object.
13821497 * @param {ObjectRegexScript} regexScript Input object
13831498 * @param {booleanSCRIPT_TYPES} isScopedscriptType IsThe thetype scriptof scopedscript to aimport character?as
13841499 */
13851500async function onRegexImportObjectChange(regexScript, isScopedscriptType) {
13861501 try {
13871502 if (!regexScript.scriptName) {
13881503 throw new Error('No script name provided.');
@@ -1391,11 +1506,21 @@ async function onRegexImportObjectChange(regexScript, isScoped) {
13911506 // Assign a new UUID
13921507 regexScript.id = uuidv4();
13931508
1394- const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
1509+ const array = getScriptsByType(scriptType);
13951510 array.push(regexScript);
13961511
13971512 ifswitch (isScopedscriptType) {
1398- await writeExtensionField(this_chid, 'regex_scripts', array);
1513+ case SCRIPT_TYPES.GLOBAL:
1514+ // will be handled by saveSettingsDebounced
1515+ break;
1516+ case SCRIPT_TYPES.SCOPED:
1517+ await saveScriptsByType(array, SCRIPT_TYPES.SCOPED);
1518+ break;
1519+ case SCRIPT_TYPES.PRESET:
1520+ await saveScriptsByType(array, SCRIPT_TYPES.PRESET);
1521+ break;
1522+ default:
1523+ break;
13991524 }
14001525
14011526 saveSettingsDebounced();
@@ -1411,9 +1536,9 @@ async function onRegexImportObjectChange(regexScript, isScoped) {
14111536/**
14121537 * Performs the import of the regex file.
14131538 * @param {File} file Input file
14141539 * @param {booleanSCRIPT_TYPES} isScopedscriptType IsThe thetype scriptof scopedscript to aimport character?as
14151540 */
14161541async function onRegexImportFileChange(file, isScopedscriptType) {
14171542 if (!file) {
14181543 toastr.error('No file provided.');
14191544 return;
@@ -1423,10 +1548,10 @@ async function onRegexImportFileChange(file, isScoped) {
14231548 const regexScripts = JSON.parse(await getFileText(file));
14241549 if (Array.isArray(regexScripts)) {
14251550 for (const regexScript of regexScripts) {
14261551 await onRegexImportObjectChange(regexScript, isScopedscriptType);
14271552 }
14281553 } else {
14291554 await onRegexImportObjectChange(regexScripts, isScopedscriptType);
14301555 }
14311556 } catch (error) {
14321557 console.log(error);
@@ -1435,45 +1560,150 @@ async function onRegexImportFileChange(file, isScoped) {
14351560 }
14361561}
14371562
1563+/**
1564+ * Determines the type of a given script.
1565+ * @param {RegexScript} script The script to check
1566+ * @returns {SCRIPT_TYPES} The script type.
1567+ */
1568+function getScriptType(script) {
1569+ for (const scriptType of Object.values(SCRIPT_TYPES)) {
1570+ const scripts = getScriptsByType(scriptType);
1571+ if (scripts.some(s => s.id === script.id)) {
1572+ return scriptType;
1573+ }
1574+ }
1575+ return SCRIPT_TYPE_UNKNOWN;
1576+}
1577+
1578+function getSelectedScripts() {
1579+ const scripts = getRegexScripts();
1580+ const selector = '#regex_container .regex-script-label:has(.regex_bulk_checkbox:checked)';
1581+ const selectedIds = Array.from(document.querySelectorAll(selector))
1582+ .map(e => e.getAttribute('id'))
1583+ .filter(id => id);
1584+ return scripts.filter(script => selectedIds.includes(script.id));
1585+}
1586+
14381587function purgeEmbeddedRegexScripts({ character }) {
14391588 const avatar = character?.avatar;
1589+ if (!avatar) {
1590+ return;
1591+ }
1592+ const checkKey = `AlertRegex_${avatar}`;
1593+ if (accountStorage.getItem(checkKey)) {
1594+ accountStorage.removeItem(checkKey);
1595+ }
1596+ disallowScopedScripts(characters?.[this_chid]);
1597+}
14401598
1441- if (avatar && extension_settings.character_allowed_regex?.includes(avatar)) {
1599+function purgePresetEmbeddedRegexScripts({ apiId, name }) {
1442- const index = extension_settings.character_allowed_regex.indexOf(avatar);
1600+ const checkKey = `AlertRegex_${apiId}_${name}`;
1443- if (index !== -1) {
1601+ if (accountStorage.getItem(checkKey)) {
1444- extension_settings.character_allowed_regex.splice(index, 1);
1602+ accountStorage.removeItem(checkKey);
1445- saveSettingsDebounced();
1446- }
14471603 }
1604+ disallowPresetScripts(apiId, name);
14481605}
14491606
14501607async function checkEmbeddedRegexScriptscheckCharEmbeddedRegexScripts() {
14511608 const chid = this_chid;
14521609
14531610 if (chid !== undefined && !selected_group) {
14541611 const avatarcharacter = characters[chid]?.avatar;
14551612 const scripts = characters[chid]?.data?.extensions?getScriptsByType(SCRIPT_TYPES.regex_scriptsSCOPED);
14561613
14571614 if (Array.isArray(scripts) && scripts.length > 0) {
14581615 if (avatar && !extension_settings.character_allowed_regex.includesisScopedScriptsAllowed(avatarcharacter)) {
14591616 const checkKey = `AlertRegex_${characters[chid]character.avatar}`;
1460-
14611617 if (!accountStorage.getItem(checkKey)) {
14621618 accountStorage.setItem(checkKey, 'true');
14631619 const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {});
14641620 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Yes' });
14651621
14661622 if (result) {
14671623 extension_settings.character_allowed_regex.pushallowScopedScripts(avatarcharacter);
14681624 await reloadCurrentChat();
1469- saveSettingsDebounced();
14701625 }
14711626 }
14721627 }
14731628 }
14741629 }
14751630
14761631 await loadRegexScripts();
1632+}
1633+
1634+/**
1635+ * Notify whether to reload current chat when preset is changed
1636+ * @param {string} presetName The name of the preset
1637+ */
1638+function notifyReloadCurrentChat(presetName) {
1639+ toastr.info(
1640+ t`Reload the chat for regex to take effect` + '<br><u>' + t`Click here to reload immediately` + '</u>',
1641+ t`Preset '${presetName}' contains enabled regex scripts`,
1642+ {
1643+ timeOut: 5000,
1644+ escapeHtml: false,
1645+ onclick: reloadCurrentChat,
1646+ });
1647+}
1648+
1649+async function checkPresetEmbeddedRegexScripts() {
1650+ const apiId = getCurrentPresetAPI();
1651+ const name = getCurrentPresetName();
1652+ const scripts = getScriptsByType(SCRIPT_TYPES.PRESET);
1653+
1654+ if (Array.isArray(scripts) && scripts.length > 0) {
1655+ if (!isPresetScriptsAllowed(apiId, name)) {
1656+ const checkKey = `AlertRegex_${apiId}_${name}`;
1657+
1658+ if (!accountStorage.getItem(checkKey)) {
1659+ accountStorage.setItem(checkKey, 'true');
1660+ const template = await renderExtensionTemplateAsync('regex', 'presetEmbeddedScripts', {});
1661+ const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '');
1662+
1663+ if (result) {
1664+ allowPresetScripts(apiId, name);
1665+ if (getCurrentChatId()) {
1666+ await reloadCurrentChat();
1667+ }
1668+ }
1669+ }
1670+ } else if (getCurrentChatId() && scripts.filter(script => !script.disabled).length > 0) {
1671+ notifyReloadCurrentChat(name);
1672+ }
1673+ }
1674+
1675+ await loadRegexScripts();
1676+}
1677+
1678+async function onMainApiChanged({ apiId }) {
1679+ const presetManager = getPresetManager(apiId);
1680+ if (!presetManager) {
1681+ return;
1682+ }
1683+ const presetName = presetManager.getSelectedPresetName();
1684+ const presetScripts = presetManager.readPresetExtensionField({ path: 'regex_scripts' }) ?? [];
1685+ if (getCurrentChatId() &&
1686+ isPresetScriptsAllowed(apiId, presetName) &&
1687+ Array.isArray(presetScripts) &&
1688+ presetScripts.filter(script => !script.disabled).length > 0) {
1689+ notifyReloadCurrentChat(presetName);
1690+ }
1691+
1692+ await loadRegexScripts();
1693+}
1694+
1695+function onPresetRenamed({ apiId, oldName, newName }) {
1696+ const oldCheckKey = `AlertRegex_${apiId}_${oldName}`;
1697+ const checkKey = `AlertRegex_${apiId}_${newName}`;
1698+ const value = accountStorage.getItem(oldCheckKey);
1699+ if (value) {
1700+ accountStorage.setItem(checkKey, value);
1701+ accountStorage.removeItem(oldCheckKey);
1702+ }
1703+ if (isPresetScriptsAllowed(apiId, oldName)) {
1704+ disallowPresetScripts(apiId, oldName);
1705+ allowPresetScripts(apiId, newName);
1706+ }
14771707}
14781708
14791709// Workaround for loading in sequence with other extensions
@@ -1497,7 +1727,7 @@ jQuery(async () => {
14971727 const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown'));
14981728 $('#regex_container').append(settingsHtml);
14991729 $('#open_regex_editor').on('click', function () {
15001730 onRegexEditorOpenClick(false, falseSCRIPT_TYPES.GLOBAL);
15011731 });
15021732 $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick);
15031733 $('#open_scoped_editor').on('click', function () {
@@ -1511,19 +1741,23 @@ jQuery(async () => {
15111741 return;
15121742 }
15131743
15141744 onRegexEditorOpenClick(false, trueSCRIPT_TYPES.SCOPED);
1745+ });
1746+ $('#open_preset_editor').on('click', function () {
1747+ onRegexEditorOpenClick(false, SCRIPT_TYPES.PRESET);
15151748 });
15161749 $('#import_regex_file').on('change', async function () {
15171750 let target = 'global'SCRIPT_TYPES.GLOBAL;
15181751 const template = $(await renderExtensionTemplateAsync('regex', 'importTarget'));
15191752 template.find('#regex_import_target_global').on('input', () => (target = 'global'SCRIPT_TYPES.GLOBAL));
15201753 template.find('#regex_import_target_scoped').on('input', () => (target = 'scoped'SCRIPT_TYPES.SCOPED));
1754+ template.find('#regex_import_target_preset').on('input', () => (target = SCRIPT_TYPES.PRESET));
15211755
15221756 await callGenericPopup(template, POPUP_TYPE.TEXT);
15231757
15241758 const inputElement = this instanceof HTMLInputElement && this;
15251759 for (const file of inputElement.files) {
15261760 await onRegexImportFileChange(file, target === 'scoped');
15271761 }
15281762 inputElement.value = '';
15291763 });
@@ -1531,13 +1765,6 @@ jQuery(async () => {
15311765 $('#import_regex_file').trigger('click');
15321766 });
15331767
1534- function getSelectedScripts() {
1535- const scripts = getRegexScripts();
1536- const selector = '#regex_container .regex-script-label:has(.regex_bulk_checkbox:checked)';
1537- const selectedIds = Array.from(document.querySelectorAll(selector)).map(e => e.getAttribute('id')).filter(id => id);
1538- return scripts.filter(script => selectedIds.includes(script.id));
1539- }
1540-
15411768 $('#bulk_select_all_toggle').on('click', async function () {
15421769 const checkboxes = $('#regex_container .regex_bulk_checkbox');
15431770 if (checkboxes.length === 0) {
@@ -1549,32 +1776,98 @@ jQuery(async () => {
15491776
15501777 checkboxes.prop('checked', newState);
15511778 setToggleAllIcon(newState);
1779+ setMoveButtonsVisibility();
15521780 });
15531781
15541782 $('#bulk_enable_regex').on('click', async function () {
1555- const scripts = getSelectedScripts().filter(script => script.disabled);
1783+ await bulkToggleRegexScripts(true);
1784+ });
1785+
1786+ $('#bulk_disable_regex').on('click', async function () {
1787+ await bulkToggleRegexScripts(false);
1788+ });
1789+
1790+ /**
1791+ * Bulk enable or disable regex scripts
1792+ * @param {boolean} newState New state to set (true = enable, false = disable)
1793+ * @returns {Promise<void>}
1794+ */
1795+ async function bulkToggleRegexScripts(newState) {
1796+ const scripts = getSelectedScripts().filter(script => script.disabled === newState);
15561797 if (scripts.length === 0) {
1557- toastr.warning(t`No regex scripts selected for enabling.`);
1798+ toastr.warning(newState
1799+ ? t`No regex scripts selected for enabling.`
1800+ : t`No regex scripts selected for disabling.`,
1801+ );
15581802 return;
15591803 }
1804+ const scriptTypesToSave = new Set();
15601805 for (const script of scripts) {
1561- script.disabled = false;
1806+ const scriptType = getScriptType(script);
1807+ scriptTypesToSave.add(scriptType);
1808+ script.disabled = !newState;
1809+ }
1810+ for (const scriptType of scriptTypesToSave) {
1811+ const scriptsOfType = getScriptsByType(scriptType);
1812+ await saveScriptsByType(scriptsOfType, scriptType);
15621813 }
1814+
15631815 saveSettingsDebounced();
15641816 await loadRegexScripts();
1565- });
15661817
1567- $('#bulk_disable_regex').on('click', async function () {
1818+ // Reload the current chat to undo previous markdown
15681819 const scriptscurrentChatId = getSelectedScripts().filtergetCurrentChatId(script => !script.disabled);
1820+ if (currentChatId) {
1821+ await reloadCurrentChat();
1822+ }
1823+ }
1824+
1825+ /**
1826+ * Bulk move regex scripts to the specified type
1827+ * @param {SCRIPT_TYPES} toType destination type
1828+ */
1829+ async function bulkMoveRegexScript(toType) {
1830+ const scripts = getSelectedScripts();
15691831 if (scripts.length === 0) {
15701832 toastr.warning(t`No regex scripts selected for disablingmoving.`);
15711833 return;
15721834 }
15731835 for (const script of scripts) {
1574- script.disabled = true;
1836+ await moveRegexScript(script, toType, getScriptType(script), false);
15751837 }
1838+
15761839 saveSettingsDebounced();
15771840 await loadRegexScripts();
1841+
1842+ // Reload the current chat to undo previous markdown
1843+ const currentChatId = getCurrentChatId();
1844+ if (currentChatId) {
1845+ await reloadCurrentChat();
1846+ }
1847+ }
1848+
1849+ $('#bulk_regex_move_to_global').on('click', async () => {
1850+ const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to global?`, POPUP_TYPE.CONFIRM);
1851+ if (!confirm) {
1852+ return;
1853+ }
1854+ await bulkMoveRegexScript(SCRIPT_TYPES.GLOBAL);
1855+ });
1856+
1857+ $('#bulk_regex_move_to_scoped').on('click', async () => {
1858+ const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to scoped?`, POPUP_TYPE.CONFIRM);
1859+ if (!confirm) {
1860+ return;
1861+ }
1862+ await bulkMoveRegexScript(SCRIPT_TYPES.SCOPED);
1863+ });
1864+
1865+ $('#bulk_regex_move_to_preset').on('click', async function () {
1866+ const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to preset?`, POPUP_TYPE.CONFIRM);
1867+ if (!confirm) {
1868+ return;
1869+ }
1870+ await bulkMoveRegexScript(SCRIPT_TYPES.PRESET);
15781871 });
15791872
15801873 $('#bulk_delete_regex').on('click', async function () {
@@ -1583,16 +1876,16 @@ jQuery(async () => {
15831876 toastr.warning(t`No regex scripts selected for deletion.`);
15841877 return;
15851878 }
15861879 const confirm = await callGenericPopup('t`Are you sure you want to delete the selected regex scripts?'`, POPUP_TYPE.CONFIRM);
15871880 if (!confirm) {
15881881 return;
15891882 }
15901883 for (const script of scripts) {
1591- const isScoped = characters[this_chid]?.data?.extensions?.regex_scripts?.some(s => s.id === script.id);
1884+ await deleteRegexScript(script.id, getScriptType(script), false);
1592- await deleteRegexScript({ id: script.id, isScoped: isScoped });
15931885 }
1594- await reloadCurrentChat();
15951886 saveSettingsDebounced();
1887+ await loadRegexScripts();
1888+ await reloadCurrentChat();
15961889 });
15971890
15981891 $('#bulk_export_regex').on('click', async function () {
@@ -1610,13 +1903,18 @@ jQuery(async () => {
16101903 let sortableDatas = [
16111904 {
16121905 selector: '#saved_regex_scripts',
16131906 setter: xscripts => extension_settings.regex =saveScriptsByType(scripts, xSCRIPT_TYPES.GLOBAL),
16141907 getter: () => extension_settingsgetScriptsByType(SCRIPT_TYPES.regex ?? []GLOBAL),
16151908 },
16161909 {
16171910 selector: '#saved_scoped_scripts',
16181911 setter: xscripts => writeExtensionFieldsaveScriptsByType(this_chid, 'regex_scripts'scripts, xSCRIPT_TYPES.SCOPED),
16191912 getter: () => characters[this_chid]?.data?.extensions?getScriptsByType(SCRIPT_TYPES.regex_scripts ?? []SCOPED),
1913+ },
1914+ {
1915+ selector: '#saved_preset_scripts',
1916+ setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.PRESET),
1917+ getter: () => getScriptsByType(SCRIPT_TYPES.PRESET),
16201918 },
16211919 ];
16221920 for (const { selector, setter, getter } of sortableDatas) {
@@ -1638,6 +1936,7 @@ jQuery(async () => {
16381936 saveSettingsDebounced();
16391937
16401938 console.debug(`Regex scripts in ${selector} reordered`);
1939+ await reloadCurrentChat();
16411940 await loadRegexScripts();
16421941 },
16431942 });
@@ -1655,17 +1954,26 @@ jQuery(async () => {
16551954 }
16561955
16571956 const isEnable = !!$(this).prop('checked');
16581957 const avatarcharacter = characters[this_chid].avatar;
16591958
16601959 if (isEnable) {
1661- if (!extension_settings.character_allowed_regex.includes(avatar)) {
1960+ allowScopedScripts(character);
1662- extension_settings.character_allowed_regex.push(avatar);
1663- }
16641961 } else {
1665- const index = extension_settings.character_allowed_regex.indexOf(avatar);
1962+ disallowScopedScripts(character);
1666- if (index !== -1) {
1963+ }
1667- extension_settings.character_allowed_regex.splice(index, 1);
1964+
1668- }
1965+ saveSettingsDebounced();
1966+ reloadCurrentChat();
1967+ });
1968+
1969+ $('#regex_preset_toggle').on('input', function () {
1970+ const isEnable = !!$(this).prop('checked');
1971+ const name = getCurrentPresetName();
1972+
1973+ if (isEnable) {
1974+ allowPresetScripts(getCurrentPresetAPI(), name);
1975+ } else {
1976+ disallowPresetScripts(getCurrentPresetAPI(), name);
16691977 }
16701978
16711979 saveSettingsDebounced();
@@ -1676,12 +1984,58 @@ jQuery(async () => {
16761984 // @ts-ignore
16771985 $('#saved_regex_scripts').sortable('enable');
16781986
1987+ /**
1988+ * @typedef {object} ScriptDecorators
1989+ * @property {string} typename
1990+ * @property {import('../../slash-commands/SlashCommandEnumValue.js').EnumType} color
1991+ * @property {string} icon
1992+ */
1993+
1994+ /**
1995+ * @param {SCRIPT_TYPES} type The script type
1996+ * @returns {ScriptDecorators} The decorators for the script type
1997+ */
1998+ function getScriptDecorators(type) {
1999+ switch (type) {
2000+ case SCRIPT_TYPES.GLOBAL:
2001+ return {
2002+ typename: 'global',
2003+ color: enumTypes.enum,
2004+ icon: 'G',
2005+ };
2006+ case SCRIPT_TYPES.SCOPED:
2007+ return {
2008+ typename: 'scoped',
2009+ color: enumTypes.name,
2010+ icon: 'S',
2011+ };
2012+ case SCRIPT_TYPES.PRESET:
2013+ return {
2014+ typename: 'preset',
2015+ color: enumTypes.name,
2016+ icon: 'P',
2017+ };
2018+ default:
2019+ return {
2020+ typename: 'Unknown',
2021+ color: enumTypes.variable,
2022+ icon: 'Unknown',
2023+ };
2024+ }
2025+ }
2026+
16792027 const localEnumProviders = {
16802028 regexScripts: () => getRegexScripts().map(script => {
1681- const isGlobal = extension_settings.regex?.some(x => x.scriptName === script.scriptName);
2029+ getRegexScripts().map(script => {
1682- return new SlashCommandEnumValue(script.scriptName, `${enumIcons.getStateIcon(!script.disabled)} [${isGlobal ? 'global' : 'scoped'}] ${script.findRegex}`,
2030+ const type = getScriptType(script);
1683- isGlobal ? enumTypes.enum : enumTypes.name, isGlobal ? 'G' : 'S');
2031+ const { typename, color, icon } = getScriptDecorators(type);
1684- }),
2032+ return new SlashCommandEnumValue(
2033+ script.scriptName,
2034+ `${enumIcons.getStateIcon(!script.disabled)} [${typename}] ${script.findRegex}`,
2035+ color,
2036+ icon,
2037+ );
2038+ }),
16852039 };
16862040
16872041 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
@@ -1750,8 +2104,12 @@ jQuery(async () => {
17502104 `,
17512105 }));
17522106
17532107 eventSource.on(event_types.CHAT_CHANGEDMAIN_API_CHANGED, checkEmbeddedRegexScriptsonMainApiChanged);
2108+ eventSource.on(event_types.CHAT_CHANGED, checkCharEmbeddedRegexScripts);
17542109 eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts);
2110+ eventSource.on(event_types.PRESET_RENAMED_BEFORE, onPresetRenamed);
2111+ eventSource.on(event_types.PRESET_CHANGED, checkPresetEmbeddedRegexScripts);
2112+ eventSource.on(event_types.PRESET_DELETED, purgePresetEmbeddedRegexScripts);
17552113
17562114 presetManager.setupEventListeners();
17572115 presetManager.registerSlashCommands();
public/scripts/extensions/regex/presetEmbeddedScripts.html+5 -0
@@ -0,0 +1,5 @@
1+<div>
2+ <h3 data-i18n="This preset has embedded regex script(s).">This preset has embedded regex script(s).</h3>
3+ <h3 data-i18n="Would you like to allow using them?">Would you like to allow using them?</h3>
4+ <div class="m-b-1" data-i18n="If you want to do it later, select 'Regex' from the extensions menu.">If you want to do it later, select "Regex" from the extensions menu.</div>
5+</div>
public/scripts/extensions/regex/scriptTemplate.html+20 -11
@@ -1,25 +1,34 @@
11<div class="regex-script-label flex-container flexnowrap">
22 <input type="checkbox" class="regex_bulk_checkbox" />
33 <span class="drag-handle menu-handle">&#9776;</span>
44 <div class="regex_script_name flexGrowflex1 overflow-hidden"></div>
55 <div class="flex-container flexnowrap">
66 <label class="checkbox flex-container margin-r5" for="regex_disable">
77 <input type="checkbox" name="regex_disable" class="disable_regex" />
88 <span class="regex-toggle-on fa-solid fa-toggle-on" data-i18n="[title]ext_regex_disable_script" title="Disable script"></span>
99 <span class="regex-toggle-off fa-solid fa-toggle-off" data-i18n="[title]ext_regex_enable_script" title="Enable script"></span>
1010 </label>
11+ <label class="menu_button regex_script_expand" title="Show more options" data-i18n="[title]Show more options">
12+ <input type="checkbox" name="regex_expand" />
13+ <span class="fa-solid fa-ellipsis"></span>
14+ </label>
15+ <div class="flex-container regex_script_buttons">
16+ <div class="move_to_global menu_button" data-i18n="[title]ext_regex_move_to_global" title="Move to global scripts">
17+ <i class="fa-solid fa-globe"></i>
18+ </div>
19+ <div class="move_to_scoped menu_button" data-i18n="[title]ext_regex_move_to_scoped" title="Move to scoped scripts">
20+ <i class="fa-solid fa-address-card"></i>
21+ </div>
22+ <div class="move_to_preset menu_button" data-i18n="[title]ext_regex_move_to_preset" title="Move to preset scripts">
23+ <i class="fa-solid fa-sliders"></i>
24+ </div>
25+ <div class="export_regex menu_button" data-i18n="[title]ext_regex_export_script" title="Export script">
26+ <i class="fa-solid fa-file-export"></i>
27+ </div>
28+ </div>
1129 <div class="edit_existing_regex menu_button" data-i18n="[title]ext_regex_edit_script" title="Edit script">
1230 <i class="fa-solid fa-pencil"></i>
1331 </div>
14- <div class="move_to_global menu_button" data-i18n="[title]ext_regex_move_to_global" title="Move to global scripts">
15- <i class="fa-solid fa-arrow-up"></i>
16- </div>
17- <div class="move_to_scoped menu_button" data-i18n="[title]ext_regex_move_to_scoped" title="Move to scoped scripts">
18- <i class="fa-solid fa-arrow-down"></i>
19- </div>
20- <div class="export_regex menu_button" data-i18n="[title]ext_regex_export_script" title="Export script">
21- <i class="fa-solid fa-file-export"></i>
22- </div>
2332 <div class="delete_regex menu_button" data-i18n="[title]ext_regex_delete_script" title="Delete script">
2433 <i class="fa-solid fa-trash"></i>
2534 </div>
public/scripts/extensions/regex/style.css+35 -26
@@ -24,39 +24,33 @@
2424 text-align: center;
2525}
2626
2727#scoped_scripts_block {,
28+#preset_scripts_block {
2829 opacity: 1;
2930 transition: opacity var(--animation-duration-2x) ease-in-out;
3031}
3132
3233#scoped_scripts_block .move_to_scoped {,
34+#global_scripts_block .move_to_global,
35+#preset_scripts_block .move_to_preset {
3336 display: none;
3437}
3538
36-#global_scripts_block .move_to_global {
39+#scoped_scripts_block:not(:has(#regex_scoped_toggle:checked)),
37- display: none;
40+#preset_scripts_block:not(:has(#regex_preset_toggle:checked)) {
38-}
39-
40-#scoped_scripts_block:not(:has(#regex_scoped_toggle:checked)) {
4141 opacity: 0.5;
4242}
4343
4444.enable_scoped:checked~.regex-toggle-on {,
45+.enable_scoped:not(:checked)~.regex-toggle-off {
4546 display: block;
4647}
4748
4849.enable_scoped:checked~.regex-toggle-off {,
49- display: none;
50-}
51-
5250.enable_scoped:not(:checked)~.regex-toggle-on {
5351 display: none;
5452}
5553
56-.enable_scoped:not(:checked)~.regex-toggle-off {
57- display: block;
58-}
59-
6054.regex-script-label {
6155 align-items: baseline;
6256 border: 1px solid var(--SmartThemeBorderColor);
@@ -92,19 +86,13 @@ input.enable_scoped {
9286 cursor: pointer;
9387}
9488
9589.disable_regex:checked~.regex-toggle-off {on,
96- display: block;
97-}
98-
99-.disable_regex:checked~.regex-toggle-on {
100- display: none;
101-}
102-
10390.disable_regex:not(:checked)~.regex-toggle-off {
10491 display: none;
10592}
10693
10794.disable_regex:not(:checked)~.regex-toggle-on {,
95+.disable_regex:checked~.regex-toggle-off {
10896 display: block;
10997}
11098
@@ -139,7 +127,8 @@ input.enable_scoped {
139127}
140128
141129.regex_settings .regex_bulk_operations,
142130.regex_settings .regex_bulk_checkbox {,
131+.regex_settings .regex_bulk_operations_hr {
143132 display: none;
144133}
145134
@@ -147,6 +136,26 @@ input.enable_scoped {
147136 display: flex;
148137}
149138
139+.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_operations_hr {
140+ display: block;
141+}
142+
150143.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_checkbox {
151144 display: inline-grid;
152145}
146+
147+@supports not selector(:has(*)) {
148+ .regex-script-label label.regex_script_expand {
149+ display: none;
150+ }
151+
152+ .regex-script-label .regex_script_buttons {
153+ display: flex;
154+ }
155+}
156+
157+.regex-script-label label.regex_script_expand input[name="regex_expand"],
158+.regex-script-label:has(input[name="regex_expand"]:checked) label.regex_script_expand,
159+.regex-script-label:not(:has(input[name="regex_expand"]:checked)) .regex_script_buttons {
160+ display: none;
161+}
public/scripts/extensions/shared.js+1 -0
@@ -410,6 +410,7 @@ export class ConnectionManagerRequestService {
410410 custom_url: profile['api-url'],
411411 reverse_proxy: proxyPreset?.url,
412412 proxy_password: proxyPreset?.password,
413+ custom_prompt_post_processing: profile['prompt-post-processing'],
413414 ...overridePayload,
414415 }, {
415416 presetName: includePreset ? profile.preset : undefined,
public/scripts/extensions/stable-diffusion/dropdown.html+1 -1
@@ -1,6 +1,6 @@
11<div id="sd_dropdown">
22 <ul class="list-group">
33 <span data-i18n="Send me a picture of:">Send me a picture of:</span>
44 <li class="list-group-item" id="sd_you" data-value="you" data-i18n="sd_Yourself">Yourself</li>
55 <li class="list-group-item" id="sd_face" data-value="face" data-i18n="sd_Your_Face">Your Face</li>
66 <li class="list-group-item" id="sd_me" data-value="me" data-i18n="sd_Me">Me</li>
public/scripts/extensions/stable-diffusion/index.js+1 -1
@@ -1705,7 +1705,7 @@ async function loadModels() {
17051705 models = await loadStabilityModels();
17061706 break;
17071707 case sources.huggingface:
17081708 models = [{ value: '', text: 't`<Enter Model ID above>'` }];
17091709 break;
17101710 case sources.electronhub:
17111711 models = await loadElectronHubModels();
public/scripts/extensions/stable-diffusion/settings.html+6 -6
@@ -90,15 +90,15 @@
9090 <i><b data-i18n="Important:">Important:</b></i><i data-i18n="sd_drawthings_auth_txt"> run DrawThings app with HTTP API switch enabled in the UI! The server must be accessible from the SillyTavern host machine.</i>
9191 </div>
9292 <div data-sd-source="huggingface">
9393 <i data-i18n="Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.">Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.</i>
9494 <label for="sd_huggingface_model_id" data-i18n="Model ID">Model ID</label>
9595 <input id="sd_huggingface_model_id" type="text" class="text_pole" data-i18n="[placeholder]e.g. black-forest-labs/FLUX.1-dev" placeholder="e.g. black-forest-labs/FLUX.1-dev" value="" />
9696 </div>
9797 <div data-sd-source="electronhub">
9898 <i data-i18n="Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.">Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.</i>
9999 </div>
100100 <div data-sd-source="nanogpt">
101101 <i data-i18n="Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.">Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.</i>
102102 </div>
103103 <div data-sd-source="vlad">
104104 <label for="sd_vlad_url">SD.Next API URL</label>
@@ -143,7 +143,7 @@
143143 View my Anlas
144144 </div>
145145 </div>
146146 <i data-i18n="Hint: Save an API key in the NovelAI API settings to use it here.">Hint: Save an API key in the NovelAI API settings to use it here.</i>
147147 </div>
148148 <div data-sd-source="aimlapi">
149149 <div class="flex-container flexnowrap alignItemsBaseline marginBot5">
@@ -204,7 +204,7 @@
204204 <a href="https://pollinations.ai">Pollinations.ai</a>
205205 </p>
206206 <div class="flex-container">
207207 <label class="flex1 checkbox_label" for="sd_pollinations_enhance" data-i18n="[title]Enables prompt enhancing (passes prompts through an LLM to add detail)." title="Enables prompt enhancing (passes prompts through an LLM to add detail).">
208208 <input id="sd_pollinations_enhance" type="checkbox" />
209209 <span data-i18n="Enhance">
210210 Enhance
@@ -297,7 +297,7 @@
297297 </div>
298298 </div>
299299 <div class="flex-container">
300300 <label class="flex1 checkbox_label" for="sd_google_enhance" data-i18n="[title]Enables prompt enhancing (passes prompts through an LLM to add detail)." title="Enables prompt enhancing (passes prompts through an LLM to add detail).">
301301 <input id="sd_google_enhance" type="checkbox" />
302302 <span data-i18n="Enhance">
303303 Enhance
public/scripts/extensions/tts/css/minimax-tts.css+5 -0
@@ -1,3 +1,8 @@
1+.minimax_tts_settings>.tts_block {
2+ gap: 5px;
3+ margin: 5px 0;
4+}
5+
16.minimax-custom-item {
27 display: flex;
38 justify-content: space-between;
public/scripts/extensions/tts/electronhub.js+455 -0
@@ -0,0 +1,455 @@
1+import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
2+import { SECRET_KEYS, secret_state } from '../../secrets.js';
3+import { getPreviewString, saveTtsProviderSettings, initVoiceMap } from './index.js';
4+
5+export { ElectronHubTtsProvider };
6+
7+class ElectronHubTtsProvider {
8+ settings;
9+ voices = [];
10+ models = [];
11+ separator = ' . ';
12+ audioElement = document.createElement('audio');
13+
14+ defaultSettings = {
15+ voiceMap: {},
16+ model: 'tts-1',
17+ speed: 1,
18+ temperature: 1,
19+ top_p: 1,
20+ // GPT-4o Mini TTS
21+ instructions: '',
22+ // Dia
23+ speaker_transcript: '',
24+ cfg_filter_top_k: 25,
25+ cfg_scale: 3,
26+ // Microsoft TTS
27+ speech_rate: 0,
28+ pitch_adjustment: 0,
29+ emotional_style: '',
30+ };
31+
32+ get settingsHtml() {
33+ let html = `
34+ <div>Electron Hub unified TTS API.</div>
35+ <div class="flex-container alignItemsCenter">
36+ <div class="flex1"></div>
37+ <div id="electronhub_tts_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_electronhub">
38+ <i class="fa-solid fa-key"></i>
39+ <span>API Key</span>
40+ </div>
41+ </div>
42+ <div class="flex-container flexGap10 wrap">
43+ <div class="flex1">
44+ <label for="electronhub_tts_model">Model</label>
45+ <select id="electronhub_tts_model" class="text_pole"></select>
46+ </div>
47+ <div>
48+ <label for="electronhub_tts_speed">Speed <span id="electronhub_tts_speed_output"></span></label>
49+ <input type="range" id="electronhub_tts_speed" value="1" min="0.25" max="4" step="0.05">
50+ </div>
51+ <div>
52+ <label for="electronhub_tts_temperature">Temperature</label>
53+ <input id="electronhub_tts_temperature" class="text_pole" type="number" min="0" max="2" step="0.1" value="1" />
54+ </div>
55+ <div id="electronhub_block_top_p" style="display:none;">
56+ <label for="electronhub_tts_top_p">Top-p</label>
57+ <input id="electronhub_tts_top_p" class="text_pole" type="number" min="0" max="1" step="0.01" value="1" />
58+ </div>
59+ </div>
60+
61+ <div id="electronhub_block_instructions" style="display:none;">
62+ <label for="electronhub_tts_instructions">Instructions (GPT-4o Mini TTS):</label>
63+ <textarea id="electronhub_tts_instructions" class="textarea_compact autoSetHeight" placeholder="e.g., 'Speak cheerfully and energetically'"></textarea>
64+ </div>
65+
66+ <div id="electronhub_block_dia" style="display:none;">
67+ <label for="electronhub_tts_speaker_transcript">Speaker transcript (Dia):</label>
68+ <textarea id="electronhub_tts_speaker_transcript" class="textarea_compact autoSetHeight" maxlength="1000"></textarea>
69+ <label for="electronhub_tts_cfg_scale">CFG scale (1-5):</label>
70+ <input id="electronhub_tts_cfg_scale" type="number" min="1" max="5" step="1" />
71+ <label for="electronhub_tts_cfg_topk">CFG filter top_k (15-50):</label>
72+ <input id="electronhub_tts_cfg_topk" type="number" min="15" max="50" step="1" />
73+ </div>
74+
75+ <div id="electronhub_block_msft" style="display:none;">
76+ <div class="flex-container flexGap10 wrap">
77+ <div>
78+ <label for="electronhub_tts_speech_rate">Speech rate (-100..100)</label>
79+ <input id="electronhub_tts_speech_rate" class="text_pole" type="number" min="-100" max="100" step="1" style="width:120px;" />
80+ </div>
81+ <div>
82+ <label for="electronhub_tts_pitch_adjustment">Pitch adjustment (-100..100)</label>
83+ <input id="electronhub_tts_pitch_adjustment" class="text_pole" type="number" min="-100" max="100" step="1" style="width:120px;" />
84+ </div>
85+ </div>
86+ <div class="flex-container flexGap10">
87+ <div class="flex1">
88+ <label for="electronhub_tts_emotional_style">Emotional style</label>
89+ <input id="electronhub_tts_emotional_style" class="text_pole" type="text" placeholder="cheerful, sad, angry, gentle..." />
90+ </div>
91+ </div>
92+ </div>
93+
94+ <div id="electronhub_dynamic_params" class="flex-container flexGap10 wrap" style="display:none;"></div>`;
95+ return html;
96+ }
97+
98+ constructor() {
99+ this.handler = async function (/** @type {string} */ key) {
100+ if (key !== SECRET_KEYS.ELECTRONHUB) return;
101+ $('#electronhub_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELECTRONHUB]);
102+ await this.onRefreshClick();
103+ }.bind(this);
104+ }
105+
106+ dispose() {
107+ [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
108+ eventSource.removeListener(event, this.handler);
109+ });
110+ }
111+
112+ async loadSettings(settings) {
113+ if (Object.keys(settings).length == 0) {
114+ console.info('Using default Electron Hub TTS settings');
115+ }
116+
117+ this.settings = { ...this.defaultSettings, ...settings };
118+
119+ await this.loadModels();
120+ this.populateModelSelect();
121+
122+ $('#electronhub_tts_model').val(this.settings.model);
123+ $('#electronhub_tts_model').on('change', () => { this.onSettingsChange(); });
124+
125+ $('#electronhub_tts_speed').val(this.settings.speed);
126+ $('#electronhub_tts_speed_output').text(this.settings.speed);
127+ $('#electronhub_tts_speed').on('input', () => { this.onSettingsChange(); });
128+
129+ $('#electronhub_tts_temperature').val(this.settings.temperature);
130+ $('#electronhub_tts_temperature').on('input', () => { this.onSettingsChange(); });
131+
132+ $('#electronhub_tts_top_p').val(this.settings.top_p);
133+ $('#electronhub_tts_top_p').on('input', () => { this.onSettingsChange(); });
134+
135+ $('#electronhub_tts_instructions').val(this.settings.instructions);
136+ $('#electronhub_tts_instructions').on('input', () => { this.onSettingsChange(); });
137+
138+ $('#electronhub_tts_speaker_transcript').val(this.settings.speaker_transcript);
139+ $('#electronhub_tts_speaker_transcript').on('input', () => { this.onSettingsChange(); });
140+ $('#electronhub_tts_cfg_scale').val(this.settings.cfg_scale);
141+ $('#electronhub_tts_cfg_scale').on('input', () => { this.onSettingsChange(); });
142+ $('#electronhub_tts_cfg_topk').val(this.settings.cfg_filter_top_k);
143+ $('#electronhub_tts_cfg_topk').on('input', () => { this.onSettingsChange(); });
144+
145+ $('#electronhub_tts_speech_rate').val(this.settings.speech_rate);
146+ $('#electronhub_tts_speech_rate').on('input', () => { this.onSettingsChange(); });
147+ $('#electronhub_tts_pitch_adjustment').val(this.settings.pitch_adjustment);
148+ $('#electronhub_tts_pitch_adjustment').on('input', () => { this.onSettingsChange(); });
149+ $('#electronhub_tts_emotional_style').val(this.settings.emotional_style);
150+ $('#electronhub_tts_emotional_style').on('input', () => { this.onSettingsChange(); });
151+
152+ $('#electronhub_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELECTRONHUB]);
153+ [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
154+ eventSource.on(event, this.handler);
155+ });
156+
157+ await this.checkReady();
158+ this.updateConditionalBlocks();
159+ this.renderDynamicParams();
160+ console.debug('Electron Hub TTS: Settings loaded');
161+ }
162+
163+ async onSettingsChange() {
164+ const previousModel = this.settings.model;
165+ this.settings.model = String($('#electronhub_tts_model').find(':selected').val() || this.settings.model);
166+ this.settings.speed = Number($('#electronhub_tts_speed').val());
167+ $('#electronhub_tts_speed_output').text(this.settings.speed);
168+ this.settings.temperature = Number($('#electronhub_tts_temperature').val());
169+ this.settings.top_p = Number($('#electronhub_tts_top_p').val());
170+ this.settings.instructions = String($('#electronhub_tts_instructions').val() || '');
171+ this.settings.speaker_transcript = String($('#electronhub_tts_speaker_transcript').val() || '');
172+ this.settings.cfg_scale = Number($('#electronhub_tts_cfg_scale').val());
173+ this.settings.cfg_filter_top_k = Number($('#electronhub_tts_cfg_topk').val());
174+ this.settings.speech_rate = Number($('#electronhub_tts_speech_rate').val());
175+ this.settings.pitch_adjustment = Number($('#electronhub_tts_pitch_adjustment').val());
176+ this.settings.emotional_style = String($('#electronhub_tts_emotional_style').val() || '');
177+ this.updateConditionalBlocks();
178+ this.renderDynamicParams();
179+ saveTtsProviderSettings();
180+ if (previousModel !== this.settings.model) {
181+ this.voices = await this.fetchTtsVoiceObjects();
182+ await initVoiceMap();
183+ }
184+ }
185+
186+ async loadModels() {
187+ try {
188+ const response = await fetch('/api/openai/electronhub/models', {
189+ method: 'POST',
190+ headers: getRequestHeaders(),
191+ });
192+ if (!response.ok) {
193+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
194+ }
195+ /** @type {Array<any>} */
196+ const data = await response.json();
197+ const allModels = Array.isArray(data) ? data : [];
198+ const ttsModels = allModels.filter(m => {
199+ const eps = Array.isArray(m?.endpoints) ? m.endpoints : [];
200+ return eps.some(ep => {
201+ if (typeof ep !== 'string') return false;
202+ return ep === '/v1/audio/speech' || ep.endsWith('/audio/speech') || ep === 'audio/speech';
203+ });
204+ });
205+
206+ this.models = ttsModels;
207+
208+ if (this.models.length > 0 && !this.models.find(m => m.id === this.settings.model)) {
209+ this.settings.model = this.models[0].id;
210+ saveTtsProviderSettings();
211+ }
212+ } catch (err) {
213+ console.warn('Electron Hub models fetch failed', err);
214+ this.models = [];
215+ }
216+ }
217+
218+ populateModelSelect() {
219+ const select = $('#electronhub_tts_model');
220+ select.empty();
221+ const groups = this.groupByVendor(this.models);
222+ for (const [vendor, models] of groups.entries()) {
223+ const optgroup = document.createElement('optgroup');
224+ optgroup.label = vendor;
225+ for (const m of models) {
226+ const opt = document.createElement('option');
227+ opt.value = m.id;
228+ opt.text = m.name || m.id;
229+ optgroup.appendChild(opt);
230+ }
231+ select.append(optgroup);
232+ }
233+
234+ if (this.models.find(x => x.id === this.settings.model)) {
235+ select.val(this.settings.model);
236+ }
237+ }
238+
239+ /**
240+ * Group models by vendor prefix from name before ':'
241+ * @param {Array<any>} array
242+ * @returns {Map<string, any[]>}
243+ */
244+ groupByVendor(array) {
245+ return array.reduce((acc, curr) => {
246+ const name = String(curr?.name || curr?.id || 'Other');
247+ const vendor = name.split(':')[0].trim() || 'Other';
248+ if (!acc.has(vendor)) acc.set(vendor, []);
249+ acc.get(vendor).push(curr);
250+ return acc;
251+ }, new Map());
252+ }
253+
254+ updateConditionalBlocks() {
255+ const modelId = this.settings.model;
256+ const model = this.models.find(m => m.id === modelId);
257+ const params = model?.parameters || {};
258+ const vendorName = String(model?.name || '').split(':')[0].trim().toLowerCase();
259+
260+ const hasInstructions = 'instructions' in params || modelId === 'gpt-4o-mini-tts';
261+ const hasDia = 'speaker_transcript' in params || 'cfg_scale' in params || 'cfg_filter_top_k' in params || modelId.includes('dia');
262+
263+ const hasMsft = 'speech_rate' in params || 'pitch_adjustment' in params || 'emotional_style' in params || vendorName === 'microsoft' || modelId === 'microsoft-tts';
264+ const hasTopP = 'top_p' in params;
265+
266+ $('#electronhub_block_instructions').toggle(!!hasInstructions);
267+ $('#electronhub_block_dia').toggle(!!hasDia);
268+ $('#electronhub_block_msft').toggle(!!hasMsft);
269+ $('#electronhub_block_top_p').toggle(!!hasTopP);
270+ }
271+
272+ /**
273+ * Build UI for additional model parameters dynamically
274+ */
275+ renderDynamicParams() {
276+ const container = $('#electronhub_dynamic_params');
277+ container.empty();
278+ const model = this.models.find(m => m.id === this.settings.model);
279+ const params = model?.parameters || {};
280+ const modelHasVoices = Array.isArray(model?.voices) && model.voices.length > 0;
281+ const exclude = new Set(['input', 'response_format', 'model', 'speed', 'temperature', 'top_p', 'instructions', 'speaker_transcript', 'cfg_scale', 'cfg_filter_top_k', 'speech_rate', 'pitch_adjustment', 'emotional_style']);
282+ if (modelHasVoices) exclude.add('voice');
283+
284+ const entries = Object.entries(params).filter(([k]) => !exclude.has(k));
285+ container.toggle(entries.length > 0);
286+ if (entries.length === 0) return;
287+
288+ for (const [key, spec] of entries) {
289+ const nice = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
290+ const type = String(spec?.type || 'string');
291+ const id = `electronhub_dyn_${key.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
292+
293+ if (Array.isArray(spec?.enum) && spec.enum.length) {
294+ const select = $(`<div><label for="${id}">${nice}</label><select id="${id}" class="text_pole"></select></div>`);
295+ container.append(select);
296+ const el = select.find('select');
297+ for (const opt of spec.enum) el.append(new Option(String(opt), String(opt)));
298+ const val = this.settings[key] ?? spec.default ?? spec.enum[0];
299+ el.val(String(val));
300+ el.on('change', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
301+ continue;
302+ }
303+
304+ if (type === 'boolean') {
305+ const block = $(`<label class="checkbox_label" for="${id}"><input type="checkbox" id="${id}"> <small>${nice}</small></label>`);
306+ container.append(block);
307+ const el = block.find('input');
308+ el.prop('checked', !!(this.settings[key] ?? spec.default ?? false));
309+ el.on('change', () => { this.settings[key] = !!el.is(':checked'); saveTtsProviderSettings(); });
310+ continue;
311+ }
312+
313+ if (type === 'number' || type === 'integer') {
314+ const min = spec.minimum ?? undefined;
315+ const max = spec.maximum ?? undefined;
316+ const step = type === 'integer' ? 1 : (spec.step ?? 0.01);
317+ const block = $(`<div><label for="${id}">${nice}${(min != null || max != null) ? ` (${min ?? ''}..${max ?? ''})` : ''}:</label><input id="${id}" type="number" class="text_pole" ${min != null ? `min="${min}"` : ''} ${max != null ? `max="${max}"` : ''} step="${step}"></div>`);
318+ container.append(block);
319+ const el = block.find('input');
320+ const val = this.settings[key] ?? spec.default ?? '';
321+ if (val !== '') el.val(val);
322+ el.on('input', () => {
323+ const raw = el.val();
324+ this.settings[key] = (raw === '') ? '' : Number(raw);
325+ saveTtsProviderSettings();
326+ });
327+ continue;
328+ }
329+
330+ const isLong = /instructions|transcript|style|prompt|description/i.test(key);
331+ if (isLong) {
332+ const block = $(`<div><label for="${id}">${nice}</label><textarea id="${id}" class="textarea_compact autoSetHeight"></textarea></div>`);
333+ container.append(block);
334+ const el = block.find('textarea');
335+ el.val(String(this.settings[key] ?? spec.default ?? ''));
336+ el.on('input', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
337+ } else {
338+ const block = $(`<div><label for="${id}">${nice}</label><input id="${id}" type="text" class="text_pole" /></div>`);
339+ container.append(block);
340+ const el = block.find('input');
341+ el.val(String(this.settings[key] ?? spec.default ?? ''));
342+ el.on('input', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
343+ }
344+ }
345+ }
346+
347+ async checkReady() {
348+ this.voices = await this.fetchTtsVoiceObjects();
349+ }
350+
351+ async onRefreshClick() {
352+ await this.loadModels();
353+ this.populateModelSelect();
354+ this.voices = await this.fetchTtsVoiceObjects();
355+ this.updateConditionalBlocks();
356+ this.renderDynamicParams();
357+ saveTtsProviderSettings();
358+ }
359+
360+ async getVoice(voiceName) {
361+ if (this.voices.length == 0) {
362+ this.voices = await this.fetchTtsVoiceObjects();
363+ }
364+ const match = this.voices.filter(v => v.name == voiceName)[0];
365+ if (!match) {
366+ throw `TTS Voice name ${voiceName} not found`;
367+ }
368+ return match;
369+ }
370+
371+ async generateTts(text, voiceId) {
372+ const response = await this.fetchTtsGeneration(text, voiceId);
373+ return response;
374+ }
375+
376+ async fetchTtsVoiceObjects() {
377+ const modelId = this.settings.model;
378+ const model = this.models.find(m => m.id === modelId);
379+ if (model && Array.isArray(model.voices) && model.voices.length) {
380+ return model.voices.map(name => ({ name, voice_id: name, lang: 'en-US' }));
381+ }
382+ // Fallback to common OpenAI voices
383+ const fallback = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer', 'verse'];
384+ return fallback.map(name => ({ name, voice_id: name, lang: 'en-US' }));
385+ }
386+
387+ async previewTtsVoice(voiceId) {
388+ this.audioElement.pause();
389+ this.audioElement.currentTime = 0;
390+ const text = getPreviewString('en-US');
391+ const response = await this.fetchTtsGeneration(text, voiceId);
392+ if (!response.ok) {
393+ throw new Error(`HTTP ${response.status}`);
394+ }
395+ const audio = await response.blob();
396+ const url = URL.createObjectURL(audio);
397+ this.audioElement.src = url;
398+ this.audioElement.play();
399+ this.audioElement.onended = () => URL.revokeObjectURL(url);
400+ }
401+
402+ async fetchTtsGeneration(inputText, voiceId) {
403+ console.info(`Generating Electron Hub TTS for voice_id ${voiceId}`);
404+ const body = {
405+ input: inputText,
406+ voice: voiceId,
407+ speed: this.settings.speed,
408+ temperature: this.settings.temperature,
409+ model: this.settings.model,
410+ };
411+
412+ const model = (this.settings.model || '').toLowerCase();
413+ if (model === 'gpt-4o-mini-tts') {
414+ if (this.settings.instructions?.trim()) body.instructions = this.settings.instructions.trim();
415+ }
416+ if (model.includes('dia')) {
417+ if (this.settings.speaker_transcript?.trim()) body.speaker_transcript = this.settings.speaker_transcript.trim();
418+ if (Number.isFinite(this.settings.cfg_scale)) body.cfg_scale = Number(this.settings.cfg_scale);
419+ if (Number.isFinite(this.settings.cfg_filter_top_k)) body.cfg_filter_top_k = Number(this.settings.cfg_filter_top_k);
420+ }
421+ if (model.includes('microsoft-tts')) {
422+ if (Number.isFinite(this.settings.speech_rate)) body.speech_rate = Number(this.settings.speech_rate);
423+ if (Number.isFinite(this.settings.pitch_adjustment)) body.pitch_adjustment = Number(this.settings.pitch_adjustment);
424+ if ((this.settings.emotional_style || '').trim()) body.emotional_style = String(this.settings.emotional_style).trim();
425+ }
426+ if (Number.isFinite(this.settings.top_p)) {
427+ body.top_p = Number(this.settings.top_p);
428+ }
429+
430+ // add dynamic params based on schema
431+ const modelObj = this.models.find(m => m.id === this.settings.model);
432+ const params = modelObj?.parameters || {};
433+ const modelHasVoices = Array.isArray(modelObj?.voices) && modelObj.voices.length > 0;
434+ const exclude = new Set(['input', 'response_format', 'model', 'speed', 'temperature', 'top_p', 'instructions', 'speaker_transcript', 'cfg_scale', 'cfg_filter_top_k', 'speech_rate', 'pitch_adjustment', 'emotional_style']);
435+ if (modelHasVoices) exclude.add('voice');
436+ for (const key of Object.keys(params)) {
437+ if (exclude.has(key)) continue;
438+ const val = this.settings[key];
439+ if (val === undefined || val === '') continue;
440+ body[key] = val;
441+ }
442+
443+ const response = await fetch('/api/openai/electronhub/generate-voice', {
444+ method: 'POST',
445+ headers: getRequestHeaders(),
446+ body: JSON.stringify(body),
447+ });
448+
449+ if (!response.ok) {
450+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
451+ }
452+
453+ return response;
454+ }
455+}
public/scripts/extensions/tts/gpt-sovits-v2.js+4 -5
@@ -92,10 +92,9 @@ class GptSovitsV2Provider {
9292 }
9393
9494 // Set initial values from the settings
9595 $('#tts_endpoint').val(this.settings.provider_endpoint).on('change', this.onSettingsChange.bind(this));
9696 $('#text_lang').val(this.settings.text_lang).on('change', this.onSettingsChange.bind(this));
9797 $('#prompt_lang').val(this.settings.prompt_lang).on('change', this.onSettingsChange.bind(this));
98-
9998
10099 await this.checkReady();
101100
@@ -108,7 +107,7 @@ class GptSovitsV2Provider {
108107 }
109108
110109 async onRefreshClick() {
111- return;
110+ return await this.checkReady();
112111 }
113112
114113 //#################//
public/scripts/extensions/tts/index.js+2 -0
@@ -33,6 +33,7 @@ import { KokoroTtsProvider } from './kokoro.js';
3333import { TtsWebuiProvider } from './tts-webui.js';
3434import { PollinationsTtsProvider } from './pollinations.js';
3535import { MiniMaxTtsProvider } from './minimax.js';
36+import { ElectronHubTtsProvider } from './electronhub.js';
3637
3738const UPDATE_INTERVAL = 1000;
3839const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -123,6 +124,7 @@ const ttsProviders = {
123124 'CosyVoice (Unofficial)': CosyVoiceProvider,
124125 Edge: EdgeTtsProvider,
125126 ElevenLabs: ElevenLabsTtsProvider,
127+ 'Electron Hub': ElectronHubTtsProvider,
126128 'Google Translate': GoogleTranslateTtsProvider,
127129 'Google Gemini TTS': GoogleNativeTtsProvider,
128130 GSVI: GSVITtsProvider,
public/scripts/extensions/tts/minimax.js+44 -16
@@ -19,9 +19,9 @@ class MiniMaxTtsProvider {
1919 apiHost: 'https://api.minimax.io',
2020 model: 'speech-02-hd',
2121 voiceMap: {},
22- speed: 1.0,
22+ speed: { default: 1.0, min: 0.5, max: 2.0, step: 0.1 },
23- volume: 1.0,
23+ volume: { default: 1.0, min: 0.0, max: 10.0, step: 0.1 },
24- pitch: 1.0,
24+ pitch: { default: 0, min: -12, max: 12, step: 1 },
2525 audioSampleRate: 32000,
2626 bitrate: 128000,
2727 format: 'mp3',
@@ -84,15 +84,15 @@ class MiniMaxTtsProvider {
8484
8585 <div class="tts_block">
8686 <label for="minimax_tts_speed">Speed: <span id="minimax_tts_speed_output"></span></label>
8787 <input id="minimax_tts_speed" type="range" value="${this.defaultSettings.speed.default}" min="0${this.5defaultSettings.speed.min}" max="2${this.0defaultSettings.speed.max}" step="0${this.1defaultSettings.speed.step}" />
8888 </div>
8989 <div class="tts_block">
9090 <label for="minimax_tts_volume">Volume: <span id="minimax_tts_volume_output"></span></label>
9191 <input id="minimax_tts_volume" type="range" value="${this.defaultSettings.volume.default}" min="0${this.1defaultSettings.volume.min}" max="2${this.0defaultSettings.volume.max}" step="0${this.1defaultSettings.volume.step}" />
9292 </div>
9393 <div class="tts_block">
9494 <label for="minimax_tts_pitch">Pitch: <span id="minimax_tts_pitch_output"></span></label>
9595 <input id="minimax_tts_pitch" type="range" value="${this.defaultSettings.pitch.default}" min="0${this.5defaultSettings.pitch.min}" max="2${this.0defaultSettings.pitch.max}" step="0${this.1defaultSettings.pitch.step}" />
9696 </div>
9797 <div class="tts_block">
9898 <label for="minimax_tts_format">Audio Format</label>
@@ -190,14 +190,14 @@ class MiniMaxTtsProvider {
190190 this.settings.apiHost = $('#minimax_tts_api_host').val();
191191 this.settings.speed = parseFloat($('#minimax_tts_speed').val().toString());
192192 this.settings.volume = parseFloat($('#minimax_tts_volume').val().toString());
193193 this.settings.pitch = parseFloatparseInt($('#minimax_tts_pitch').val().toString());
194194 this.settings.model = $('#minimax_tts_model').find(':selected').val();
195195 this.settings.format = $('#minimax_tts_format').find(':selected').val();
196196 this.settings.customVoiceId = $('#minimax_tts_custom_voice_id').val();
197197
198198 $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));
199199 $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));
200200 $('#minimax_tts_pitch_output').text(this.settings.pitch.toFixed(1));
201201
202202 saveTtsProviderSettings();
203203 }
@@ -458,6 +458,16 @@ class MiniMaxTtsProvider {
458458 // Only accept keys defined in defaultSettings
459459 this.settings = { ...this.defaultSettings };
460460
461+ // Flatten the settings fields with default/min/max definitions so the actual values are used
462+ this.settings = Object.fromEntries(
463+ Object.entries(this.defaultSettings).map(([key, value]) => {
464+ if (value && typeof value === 'object' && 'default' in value) {
465+ return [key, value.default];
466+ }
467+ return [key, value];
468+ }),
469+ );
470+
461471 for (const key in settings) {
462472 if (key in this.settings) {
463473 this.settings[key] = settings[key];
@@ -470,6 +480,21 @@ class MiniMaxTtsProvider {
470480 if (!this.settings.customModels) this.settings.customModels = [];
471481 if (!this.settings.customVoices) this.settings.customVoices = [];
472482
483+ // # Migrate settings
484+ // Pitch value changed from float to int. If it's a float, let's try to extrapolate it to the new range
485+ if (!Number.isInteger(this.settings.pitch)) {
486+ const oldPitch = parseFloat(this.settings.pitch);
487+ if (!isNaN(oldPitch)) {
488+ // map old [0.5..1.0] to [-12..0], and [1.0..2.0] to [0..12] (old default was 1.0, new default is 0)
489+ const newPitch = (oldPitch < 1.0) ? (oldPitch - 1.0) * 24 : (oldPitch - 1.0) * 12;
490+ this.settings.pitch = Math.max(-12, Math.min(12, Math.round(newPitch)));
491+ console.info(`MiniMax TTS: Migrated pitch from ${oldPitch} to ${this.settings.pitch}`);
492+ } else {
493+ this.settings.pitch = 0;
494+ console.info(`MiniMax TTS: Migration reset pitch to default ${this.settings.pitch}`);
495+ }
496+ }
497+
473498 $('#minimax_tts_api_host').val(this.settings.apiHost || 'https://api.minimax.io');
474499 $('#minimax_tts_model').val(this.settings.model);
475500 $('#minimax_tts_speed').val(this.settings.speed);
@@ -546,7 +571,7 @@ class MiniMaxTtsProvider {
546571
547572 $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));
548573 $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));
549574 $('#minimax_tts_pitch_output').text(this.settings.pitch.toFixed(1));
550575
551576 // Initialize custom configuration display
552577 this.updateCustomModelsDisplay();
@@ -756,17 +781,20 @@ class MiniMaxTtsProvider {
756781 throw error;
757782 }
758783
784+ /** @param {number} number @param {number} lower @param {number} upper @returns {number} */
785+ const clamp = (number, lower, upper) => Math.min(Math.max(number, lower), upper);
786+
759787 const requestBody = {
760788 text: inputText,
761789 voiceId: voiceId,
762790 apiHost: this.settings.apiHost,
763791 model: this.settings.model || 'speech-02-hd'this.defaultSettings.model,
764792 speed: clamp(Number(this.settings.speed) || 1this.0defaultSettings.speed.default, this.defaultSettings.speed.min, this.defaultSettings.speed.max),
765793 volume: clamp(Number(this.settings.volume) || 1this.0defaultSettings.volume.default, this.defaultSettings.volume.min, this.defaultSettings.volume.max),
766794 pitch: clamp(Math.round(Number(this.settings.pitch)) || 1this.0defaultSettings.pitch.default, this.defaultSettings.pitch.min, this.defaultSettings.pitch.max),
767795 audioSampleRate: Number(this.settings.audioSampleRate) || 32000this.defaultSettings.audioSampleRate,
768796 bitrate: Number(this.settings.bitrate) || 128000this.defaultSettings.bitrate,
769797 format: this.settings.format || 'mp3'this.defaultSettings.format,
770798 language: language,
771799 };
772800
public/scripts/extensions/tts/settings.html+2 -2
@@ -12,7 +12,7 @@
1212 <div class="tts_block">
1313 <select id="tts_provider" class="flex1">
1414 </select>
1515 <input id="tts_refresh" data-i18n="[value]tts_refresh" class="menu_button" type="submit" value="Reload" />
1616 </div>
1717 <div>
1818 <label class="checkbox_label" for="tts_enabled">
@@ -88,7 +88,7 @@
8888 <form id="tts_provider_settings">
8989 </form>
9090 <div class="tts_buttons">
9191 <input id="tts_voices" class="menu_button" data-i18n="[value]Available voices" type="submit" value="Available voices" />
9292 </div>
9393 </div>
9494 </div>
public/scripts/extensions/tts/system.js+5 -4
@@ -2,6 +2,7 @@ import { isMobile } from '../../RossAscends-mods.js';
22import { getPreviewString } from './index.js';
33import { saveTtsProviderSettings } from './index.js';
44export { SystemTtsProvider };
5+import { t } from '../../i18n.js';
56
67/**
78 * Chunkify
@@ -96,13 +97,13 @@ class SystemTtsProvider {
9697
9798 get settingsHtml() {
9899 if (!('speechSynthesis' in window)) {
99100 return 't`Your browser or operating system doesn\'t support speech synthesis'`;
100101 }
101102
102103 return `'<p>' + t`Uses the voices provided by your operating system` + `</p>
103104 <label for="system_tts_rate">` + t`Rate:` + ` <span id="system_tts_rate_output"></span></label>
104105 <input id="system_tts_rate" type="range" value="${this.defaultSettings.rate}" min="0.1" max="2" step="0.01" />
105106 <label for="system_tts_pitch">` + t`Pitch:` + ` <span id="system_tts_pitch_output"></span></label>
106107 <input id="system_tts_pitch" type="range" value="${this.defaultSettings.pitch}" min="0" max="2" step="0.01" />`;
107108 }
108109
public/scripts/extensions/vectors/index.js+1 -1
@@ -66,7 +66,7 @@ const settings = {
6666 ollama_keep: false,
6767 vllm_model: '',
6868 webllm_model: '',
6969 google_model: 'text-embedding-004005',
7070 summarize: false,
7171 summarize_sent: false,
7272 summary_source: 'main',
public/scripts/extensions/vectors/settings.html+1 -0
@@ -140,6 +140,7 @@
140140 <option value="gemini-embedding-001">gemini-embedding-001</option>
141141 <option value="gemini-embedding-exp-03-07">gemini-embedding-exp-03-07</option>
142142 <option value="text-embedding-004">text-embedding-004</option>
143+ <option value="text-embedding-005">text-embedding-005</option>
143144 <option value="embedding-001">embedding-001</option>
144145 </select>
145146 </div>
public/scripts/group-chats.js+56 -39
@@ -76,6 +76,7 @@ import {
7676 depth_prompt_role_default,
7777 shouldAutoContinue,
7878 unshallowCharacter,
79+ chatElement,
7980} from '../script.js';
8081import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
8182import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -236,37 +237,37 @@ export async function getGroupChat(groupId, reload = false) {
236237 const chat_id = group.chat_id;
237238 const data = await loadGroupChat(chat_id);
238239 const metadata = group.chat_metadata ?? {};
239240 letconst freshChat = false!metadata.tainted;
240241
241242 await loadItemizedPrompts(getCurrentChatId());
242243
243244 if (group && Array.isArray(datagroup.members) && data.lengthfreshChat) {
244- data[0].is_group = true;
245+ chat.splice(0, chat.length);
245- chat.splice(0, chat.length, ...data);
246+ chatElement.find('.mes').remove();
246- await printMessages();
247+ for (let member of group.members) {
247- } else {
248+ const character = characters.find(x => x.avatar === member || x.name === member);
248- freshChat = !metadata.tainted;
249+ if (!character) {
249- if (group && Array.isArray(group.members) && freshChat) {
250+ continue;
250- for (let member of group.members) {
251+ }
251- const character = characters.find(x => x.avatar === member || x.name === member);
252- if (!character) {
253- continue;
254- }
255-
256- const mes = await getFirstCharacterMessage(character);
257252
258- // No first message
253+ const mes = await getFirstCharacterMessage(character);
259- if (!(mes?.mes)) {
260- continue;
261- }
262254
263- chat.push(mes);
255+ // No first message
264- await eventSource.emit(event_types.MESSAGE_RECEIVED, (chat.length - 1), 'first_message');
256+ if (!(mes?.mes)) {
265- addOneMessage(mes);
257+ continue;
266- await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, (chat.length - 1), 'first_message');
267258 }
268- await saveGroupChat(groupId, false);
259+
260+ chat.push(mes);
261+ await eventSource.emit(event_types.MESSAGE_RECEIVED, (chat.length - 1), 'first_message');
262+ addOneMessage(mes);
263+ await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, (chat.length - 1), 'first_message');
269264 }
265+ await saveGroupChat(groupId, false);
266+ } else if (Array.isArray(data) && data.length) {
267+ data[0].is_group = true;
268+ chat.splice(0, chat.length, ...data);
269+ chatElement.find('.mes').remove();
270+ await printMessages();
270271 }
271272
272273 updateChatMetadata(metadata, true);
@@ -2018,7 +2019,14 @@ export async function deleteGroupChatByName(groupId, chatName) {
20182019 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatName);
20192020}
20202021
2021-export async function deleteGroupChat(groupId, chatId) {
2022+/**
2023+ * Deletes a group chat by name.
2024+ * @param {string} groupId The ID of the group containing the chat to delete.
2025+ * @param {string} chatId The id/name of the chat to delete.
2026+ * @param {object} [options={}] Options for the deletion.
2027+ * @param {boolean} [options.jumpToNewChat=true] Whether to jump to a new chat after deletion (existing one, or create a new one if none exists)
2028+ */
2029+export async function deleteGroupChat(groupId, chatId, { jumpToNewChat = true } = {}) {
20222030 const group = groups.find(x => x.id === groupId);
20232031
20242032 if (!group || !group.chats.includes(chatId)) {
@@ -2026,10 +2034,13 @@ export async function deleteGroupChat(groupId, chatId) {
20262034 }
20272035
20282036 group.chats.splice(group.chats.indexOf(chatId), 1);
2029- group.chat_metadata = {};
2030- group.chat_id = '';
20312037 delete group.past_metadata[chatId];
2032- updateChatMetadata(group.chat_metadata, true);
2038+
2039+ if (group.chat_id === chatId) {
2040+ group.chat_id = '';
2041+ group.chat_metadata = {};
2042+ updateChatMetadata(group.chat_metadata, true);
2043+ }
20332044
20342045 const response = await fetch('/api/chats/group/delete', {
20352046 method: 'POST',
@@ -2038,10 +2049,12 @@ export async function deleteGroupChat(groupId, chatId) {
20382049 });
20392050
20402051 if (response.ok) {
20412052 if (group.chats.lengthjumpToNewChat) {
20422053 awaitif openGroupChat(groupId, group.chats[group.chats.length - 1]); {
2043- } else {
2054+ await openGroupChat(groupId, group.chats[group.chats.length - 1]);
2044- await createNewGroupChat(groupId);
2055+ } else {
2056+ await createNewGroupChat(groupId);
2057+ }
20452058 }
20462059
20472060 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatId);
@@ -2051,9 +2064,11 @@ export async function deleteGroupChat(groupId, chatId) {
20512064/**
20522065 * Imports a group chat from a file and adds it to the group.
20532066 * @param {FormData} formData Form data to send to the server
20542067 * @param {EventTargetobject} eventTarget Element[options={}] thatOptions triggeredfor the import
2068+ * @param {boolean} [options.refresh] Whether to refresh the group chat list after import
2069+ * @returns {Promise<string[]>} List of imported file names
20552070 */
20562071export async function importGroupChat(formData, eventTarget{ refresh = true } = {}) {
20572072 const fetchResult = await fetch('/api/chats/group/import', {
20582073 method: 'POST',
20592074 headers: getRequestHeaders({ omitContentType: true }),
@@ -2070,14 +2085,16 @@ export async function importGroupChat(formData, eventTarget) {
20702085 if (group) {
20712086 group.chats.push(chatId);
20722087 await editGroup(selected_group, true, true);
20732088 awaitif displayPastChats(refresh); {
2089+ await displayPastChats();
2090+ }
20742091 }
20752092 }
2076- }
20772093
2078- if (eventTarget instanceof HTMLInputElement) {
2094+ return data?.fileNames || [];
2079- eventTarget.value = '';
20802095 }
2096+
2097+ return [];
20812098}
20822099
20832100export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
@@ -2147,7 +2164,7 @@ function doCurMemberListPopout() {
21472164 // Remove pagination from popout
21482165 newElement.find('.group_pagination').empty();
21492166
21502167 $('body#movingDivs').append(newElement);
21512168 loadMovingUIState();
21522169 $('#groupMemberListPopout').fadeIn(animation_duration);
21532170 dragElement(newElement);
public/scripts/macros.js+31 -1
@@ -1,10 +1,11 @@
11import { Handlebars, moment, seedrandom, droll } from '../lib.js';
22import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams, eventSource, event_types, extension_prompts } from '../script.js';
33import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';
44import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';
55import { getInstructMacros } from './instruct-mode.js';
66import { getVariableMacros } from './variables.js';
77import { isMobile } from './RossAscends-mods.js';
8+import { inject_ids } from './constants.js';
89
910/**
1011 * @typedef Macro
@@ -56,6 +57,24 @@ export class MacrosParser {
5657 };
5758
5859 /**
60+ * Access a macro by its name.
61+ * @param {string} key Macro name (key)
62+ * @returns {string|MacroFunction|undefined} The macro value
63+ */
64+ static get(key) {
65+ return MacrosParser.#macros.get(key);
66+ }
67+
68+ /**
69+ * Checks if a macro is registered.
70+ * @param {string} key Macro name (key)
71+ * @returns {boolean} True if the macro is registered, false otherwise
72+ */
73+ static has(key) {
74+ return MacrosParser.#macros.has(key);
75+ }
76+
77+ /**
5978 * Registers a global macro that can be used anywhere where substitution is allowed.
6079 * @param {string} key Macro name (key)
6180 * @param {string|MacroFunction} value A string or a function that returns a string
@@ -459,6 +478,16 @@ function getTimeDiffMacro() {
459478}
460479
461480/**
481+ * Returns the outlet prompt for a given outlet key.
482+ * @param {string} key - The outlet key
483+ * @returns {string} The outlet prompt
484+ */
485+function getOutletPrompt(key) {
486+ const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(key)]?.value;
487+ return value || '';
488+}
489+
490+/**
462491 * Substitutes {{macro}} parameters in a string.
463492 * @param {string} content - The string to substitute parameters in.
464493 * @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param
@@ -518,6 +547,7 @@ export function evaluateMacros(content, env, postProcessFn) {
518547 { regex: /{{datetimeformat +([^}]*)}}/gi, replace: (_, format) => moment().format(format) },
519548 { regex: /{{idle_duration}}/gi, replace: () => getTimeSinceLastMessage() },
520549 { regex: /{{time_UTC([-+]\d+)}}/gi, replace: (_, offset) => moment().utc().utcOffset(parseInt(offset, 10)).format('LT') },
550+ { regex: /{{outlet::(.+?)}}/gi, replace: (_, key) => getOutletPrompt(key.trim()) || '' },
521551 getTimeDiffMacro(),
522552 getBannedWordsMacro(),
523553 getRandomReplaceMacro(),
public/scripts/openai.js+223 -126
@@ -276,6 +276,8 @@ export const settingsToUpdate = {
276276 perplexity_model: ['#model_perplexity_select', 'perplexity_model', false, true],
277277 groq_model: ['#model_groq_select', 'groq_model', false, true],
278278 electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],
279+ electronhub_sort_models: ['#electronhub_sort_models', 'electronhub_sort_models', false, true],
280+ electronhub_group_models: ['#electronhub_group_models', 'electronhub_group_models', false, true],
279281 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],
280282 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],
281283 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],
@@ -370,15 +372,17 @@ const default_settings = {
370372 scenario_format: default_scenario_format,
371373 personality_format: default_personality_format,
372374 openai_model: 'gpt-4-turbo',
373375 claude_model: 'claude-3-5-sonnet-202406204-5',
374376 google_model: 'gemini-12.5-pro',
375377 vertexai_model: 'gemini-2.0-flash5-001pro',
376378 ai21_model: 'jamba-large',
377379 mistralai_model: 'mistral-large-latest',
378380 cohere_model: 'command-r-plus',
379381 perplexity_model: 'sonar-pro',
380382 groq_model: 'llama-3.3-70b-versatile',
381383 electronhub_model: 'gpt-4o-mini',
384+ electronhub_sort_models: 'alphabetically',
385+ electronhub_group_models: false,
382386 nanogpt_model: 'gpt-4o-mini',
383387 deepseek_model: 'deepseek-chat',
384388 aimlapi_model: 'gpt-4o-mini-2024-07-18',
@@ -464,15 +468,17 @@ const oai_settings = {
464468 scenario_format: default_scenario_format,
465469 personality_format: default_personality_format,
466470 openai_model: 'gpt-4-turbo',
467471 claude_model: 'claude-3-5-sonnet-202406204-5',
468472 google_model: 'gemini-12.5-pro',
469473 vertexai_model: 'gemini-2.0-flash5-001pro',
470474 ai21_model: 'jamba-large',
471475 mistralai_model: 'mistral-large-latest',
472476 cohere_model: 'command-r-plus',
473477 perplexity_model: 'sonar-pro',
474478 groq_model: 'llama-3.1-70b-versatile',
475479 electronhub_model: 'gpt-4o-mini',
480+ electronhub_sort_models: 'alphabetically',
481+ electronhub_group_models: false,
476482 nanogpt_model: 'gpt-4o-mini',
477483 deepseek_model: 'deepseek-chat',
478484 aimlapi_model: 'gpt-4-turbo',
@@ -1710,6 +1716,59 @@ function calculateOpenRouterCost() {
17101716 $('#openrouter_max_prompt_cost').text(cost);
17111717}
17121718
1719+function getElectronHubModelTemplate(option) {
1720+ const model = model_list.find(x => x.id === option?.element?.value);
1721+
1722+ if (!option.id || !model) {
1723+ return option.text;
1724+ }
1725+
1726+ const inputPrice = model.pricing?.input;
1727+ const outputPrice = model.pricing?.output;
1728+ const price = inputPrice && outputPrice ? `$${inputPrice}/$${outputPrice} in/out Mtoken` : 'Unknown';
1729+
1730+ const visionIcon = model.metadata?.vision ? '<i class="fa-solid fa-eye fa-sm" title="This model supports vision"></i>' : '';
1731+ const reasoningIcon = model.metadata?.reasoning ? '<i class="fa-solid fa-brain fa-sm" title="This model supports reasoning"></i>' : '';
1732+ const toolCallsIcon = model.metadata?.function_call ? '<i class="fa-solid fa-wrench fa-sm" title="This model supports function tools"></i>' : '';
1733+ const premiumIcon = model?.premium_model ? '<i class="fa-solid fa-crown fa-sm" title="This model requires a subscription"></i>' : '';
1734+
1735+ const iconsContainer = document.createElement('span');
1736+ iconsContainer.insertAdjacentHTML('beforeend', visionIcon);
1737+ iconsContainer.insertAdjacentHTML('beforeend', reasoningIcon);
1738+ iconsContainer.insertAdjacentHTML('beforeend', toolCallsIcon);
1739+ iconsContainer.insertAdjacentHTML('beforeend', premiumIcon);
1740+
1741+ const capabilities = (iconsContainer.children.length) ? ` | ${iconsContainer.innerHTML}` : '';
1742+
1743+ return $((`
1744+ <div class="flex-container alignItemsBaseline" title="${DOMPurify.sanitize(model.id)}">
1745+ <strong>${DOMPurify.sanitize(model.name)}</strong> | ${model.tokens} ctx | <small>${price}</small>${capabilities}
1746+ </div>
1747+ `));
1748+}
1749+
1750+function calculateElectronHubCost() {
1751+ if (oai_settings.chat_completion_source !== chat_completion_sources.ELECTRONHUB) {
1752+ return;
1753+ }
1754+
1755+ let cost = 'Unknown';
1756+ const model = model_list.find(x => x.id === oai_settings.electronhub_model);
1757+
1758+ if (model?.pricing) {
1759+ const outputCost = Number(model.pricing.output / 1000000);
1760+ const inputCost = Number(model.pricing.input / 1000000);
1761+ const outputTokens = oai_settings.openai_max_tokens;
1762+ const inputTokens = (oai_settings.openai_max_context - outputTokens);
1763+ const totalCost = (outputCost * outputTokens) + (inputCost * inputTokens);
1764+ if (!isNaN(totalCost)) {
1765+ cost = '$' + totalCost.toFixed(4);
1766+ }
1767+ }
1768+
1769+ $('#electronhub_max_prompt_cost').text(cost);
1770+}
1771+
17131772function saveModelList(data) {
17141773 model_list = data.map((model) => ({ ...model }));
17151774 model_list.sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id));
@@ -1775,44 +1834,29 @@ function saveModelList(data) {
17751834 }
17761835
17771836 if (oai_settings.chat_completion_source == chat_completion_sources.MISTRALAI) {
1778- /** @type {HTMLSelectElement} */
1837+ $('#model_mistralai_select').empty();
1779- const mistralModelSelect = document.querySelector('#model_mistralai_select');
1780- if (mistralModelSelect) {
1781- const options = Array.from(mistralModelSelect.options);
1782- options.forEach((option) => {
1783- const existingModel = model_list.find(model => model.id === option.value);
1784- if (!existingModel) {
1785- option.remove();
1786- }
1787- });
1788-
1789- const otherOptionsGroup = mistralModelSelect.querySelector('#mistralai_other_models');
1790- for (const model of model_list.filter(model => model?.capabilities?.completion_chat)) {
1791- if (!options.some(option => option.value === model.id) && otherOptionsGroup) {
1792- otherOptionsGroup.append(new Option(model.id, model.id));
1793- }
1794- }
17951838
17961839 for (const selectedModelmodel =of model_list.findfilter(model => model?.id === oai_settingscapabilities?.mistralai_modelcompletion_chat);) {
1797- if (!selectedModel) {
1840+ $('#model_mistralai_select').append(new Option(model.id, model.id));
1798- oai_settings.mistralai_model = model_list.find(model => model?.capabilities?.completion_chat)?.id;
1841+ }
1799- }
18001842
1801- $('#model_mistralai_select').val(oai_settings.mistralai_model).trigger('change');
1843+ const selectedModel = model_list.find(model => model.id === oai_settings.mistralai_model);
1844+ if (!selectedModel) {
1845+ oai_settings.mistralai_model = model_list.find(model => model?.capabilities?.completion_chat)?.id;
18021846 }
1847+
1848+ $('#model_mistralai_select').val(oai_settings.mistralai_model).trigger('change');
18031849 }
18041850
18051851 if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {
1852+ model_list = model_list.filter(model => model?.endpoints?.includes('/v1/chat/completions'));
1853+
1854+ model_list = electronHubSortBy(model_list, oai_settings.electronhub_sort_models);
1855+
18061856 $('#model_electronhub_select').empty();
1807- model_list.forEach((model) => {
1857+
1808- if (model?.endpoints?.includes('/v1/chat/completions')) {
1858+ const groupedList = oai_settings.electronhub_group_models ? electronHubGroupByVendor(model_list) : model_list;
1809- $('#model_electronhub_select').append(
1859+ appendElectronHubOptions(groupedList, oai_settings.electronhub_group_models);
1810- $('<option>', {
1811- value: model.id,
1812- text: model.name,
1813- }));
1814- }
1815- });
18161860
18171861 const selectedModel = model_list.find(model => model.id === oai_settings.electronhub_model);
18181862 if (model_list.length > 0 && (!selectedModel || !oai_settings.electronhub_model)) {
@@ -1877,7 +1921,7 @@ function saveModelList(data) {
18771921 }
18781922
18791923 if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) {
18801924 // Clear only the "Other" optgroup for dynamic models
18811925 $('#google_other_models').empty();
18821926
18831927 // Get static model options that are already in the HTML
@@ -1888,7 +1932,7 @@ function saveModelList(data) {
18881932
18891933 // Add dynamic models to the "Other" group
18901934 model_list.forEach((model) => {
18911935 // Only add if not already in static list
18921936 if (!staticModels.includes(model.id)) {
18931937 $('#google_other_models').append(
18941938 $('<option>', {
@@ -1984,6 +2028,24 @@ function saveModelList(data) {
19842028 .append(new Option(modelId || 'None', modelId || '', true, true))
19852029 .trigger('change');
19862030 }
2031+
2032+ if (oai_settings.chat_completion_source == chat_completion_sources.XAI) {
2033+ $('#model_xai_select').empty();
2034+ model_list.forEach((model) => {
2035+ $('#model_xai_select').append(
2036+ $('<option>', {
2037+ value: model.id,
2038+ text: model.id,
2039+ }));
2040+ });
2041+
2042+ const selectedModel = model_list.find(model => model.id === oai_settings.xai_model);
2043+ if (model_list.length > 0 && (!selectedModel || !oai_settings.xai_model)) {
2044+ oai_settings.xai_model = model_list[0].id;
2045+ }
2046+
2047+ $('#model_xai_select').val(oai_settings.xai_model).trigger('change');
2048+ }
19872049}
19882050
19892051function appendOpenRouterOptions(model_list, groupModels = false, sort = false) {
@@ -2041,6 +2103,61 @@ function openRouterGroupByVendor(array) {
20412103 }, new Map());
20422104}
20432105
2106+function appendElectronHubOptions(model_list, groupModels = false) {
2107+ const appendOption = (model, parent = null) => {
2108+ (parent || $('#model_electronhub_select')).append(
2109+ $('<option>', {
2110+ value: model.id,
2111+ text: model.name,
2112+ }));
2113+ };
2114+
2115+ if (groupModels) {
2116+ model_list.forEach((models, vendor) => {
2117+ const optgroup = $('<optgroup>').attr('label', vendor);
2118+
2119+ models.forEach((model) => {
2120+ appendOption(model, optgroup);
2121+ });
2122+
2123+ $('#model_electronhub_select').append(optgroup);
2124+ });
2125+ } else {
2126+ model_list.forEach((model) => {
2127+ appendOption(model);
2128+ });
2129+ }
2130+
2131+}
2132+
2133+function electronHubSortBy(data, property = 'alphabetically') {
2134+ return data.sort((a, b) => {
2135+ if (property === 'context_length') {
2136+ return b.tokens - a.tokens;
2137+ } else if (property === 'pricing.input') {
2138+ return parseFloat(a.pricing.input) - parseFloat(b.pricing.input);
2139+ } else if (property === 'pricing.output') {
2140+ return parseFloat(a.pricing.output) - parseFloat(b.pricing.output);
2141+ } else {
2142+ return a?.name && b?.name && a.name.localeCompare(b.name);
2143+ }
2144+ });
2145+}
2146+
2147+function electronHubGroupByVendor(array) {
2148+ return array.reduce((acc, curr) => {
2149+ const vendor = String(curr?.name || curr?.id || 'Other').split(':')[0].trim() || 'Other';
2150+
2151+ if (!acc.has(vendor)) {
2152+ acc.set(vendor, []);
2153+ }
2154+
2155+ acc.get(vendor).push(curr);
2156+
2157+ return acc;
2158+ }, new Map());
2159+}
2160+
20442161function aimlapiGroupByVendor(array) {
20452162 return array.reduce((acc, curr) => {
20462163 const vendor = curr.info.developer;
@@ -2191,7 +2308,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
21912308 const useLogprobs = !!power_user.request_token_probabilities;
21922309 const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isAzureOpenAI || isCustom || isXAI || isAimlapi || isMoonshot);
21932310
21942311 const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.ELECTRONHUB, chat_completion_sources.CUSTOM];
21952312 if (oai_settings.bias_preset_selected
21962313 && logitBiasSources.includes(oai_settings.chat_completion_source)
21972314 && Array.isArray(oai_settings.bias_presets[oai_settings.bias_preset_selected])
@@ -2353,19 +2470,24 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
23532470 }
23542471
23552472 if (isXAI) {
2356- if (generate_data.model.includes('grok-4')) {
2473+ const model = generate_data.model;
2474+ if (model.includes('grok-3-mini')) {
23572475 delete generate_data.presence_penalty;
23582476 delete generate_data.frequency_penalty;
23592477 delete generate_data.stop;
2478+ } else {
2479+ // As of 2025/09/21, only grok-3-mini accepts reasoning_effort
23602480 delete generate_data.reasoning_effort;
23612481 }
2362- if (generate_data.model.includes('grok-3-mini')) {
2482+
2483+ if (model.includes('grok-4') || model.includes('grok-code')) {
23632484 delete generate_data.presence_penalty;
23642485 delete generate_data.frequency_penalty;
2365- }
2486+
2366- if (generate_data.model.includes('grok-vision')) {
2487+ // grok-4-fast-non-reasoning accepts stop
2367- delete generate_data.tools;
2488+ if (!model.includes('grok-4-fast-non-reasoning')) {
23682489 delete generate_data.tool_choicestop;
2490+ }
23692491 }
23702492 }
23712493
@@ -2559,7 +2681,7 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov
25592681 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
25602682 }
25612683 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
25622684 } else if ([chat_completion_sources.CUSTOM, chat_completion_sources.POLLINATIONS, chat_completion_sources.AIMLAPI, chat_completion_sources.MOONSHOT, chat_completion_sources.COMETAPI, chat_completion_sources.ELECTRONHUB, chat_completion_sources.NANOGPT].includes(chat_completion_source)) {
25632685 if (show_thoughts) {
25642686 state.reasoning +=
25652687 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
@@ -3523,6 +3645,8 @@ function loadOpenAISettings(data, settings) {
35233645 oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;
35243646 oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;
35253647 oai_settings.electronhub_model = settings.electronhub_model ?? default_settings.electronhub_model;
3648+ oai_settings.electronhub_sort_models = settings.electronhub_sort_models ?? default_settings.electronhub_sort_models;
3649+ oai_settings.electronhub_group_models = settings.electronhub_group_models ?? default_settings.electronhub_group_models;
35263650 oai_settings.nanogpt_model = settings.nanogpt_model ?? default_settings.nanogpt_model;
35273651 oai_settings.deepseek_model = settings.deepseek_model ?? default_settings.deepseek_model;
35283652 oai_settings.aimlapi_model = settings.aimlapi_model ?? default_settings.aimlapi_model;
@@ -3666,6 +3790,8 @@ function loadOpenAISettings(data, settings) {
36663790 $('#openrouter_allow_fallbacks').prop('checked', oai_settings.openrouter_allow_fallbacks);
36673791 $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change');
36683792 $('#openrouter_middleout').val(oai_settings.openrouter_middleout);
3793+ $('#electronhub_sort_models').val(oai_settings.electronhub_sort_models);
3794+ $('#electronhub_group_models').prop('checked', oai_settings.electronhub_group_models);
36693795 $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages);
36703796 $('#continue_prefill').prop('checked', oai_settings.continue_prefill);
36713797 $('#openai_function_calling').prop('checked', oai_settings.function_calling);
@@ -3928,6 +4054,8 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
39284054 pollinations_model: settings.pollinations_model,
39294055 aimlapi_model: settings.aimlapi_model,
39304056 electronhub_model: settings.electronhub_model,
4057+ electronhub_sort_models: settings.electronhub_sort_models,
4058+ electronhub_group_models: settings.electronhub_group_models,
39314059 moonshot_model: settings.moonshot_model,
39324060 fireworks_model: settings.fireworks_model,
39334061 cometapi_model: settings.cometapi_model,
@@ -4532,75 +4660,8 @@ function getMistralMaxContext(model, isUnlocked) {
45324660 }
45334661 }
45344662
4535- const contextMap = {
4536- 'codestral-2405': 32768,
4537- 'codestral-2411-rc5': 262144,
4538- 'codestral-2412': 262144,
4539- 'codestral-2501': 262144,
4540- 'codestral-2508': 256000,
4541- 'codestral-latest': 256000,
4542- 'codestral-mamba-2407': 262144,
4543- 'codestral-mamba-latest': 262144,
4544- 'open-codestral-mamba': 262144,
4545- 'ministral-3b-2410': 131072,
4546- 'ministral-3b-latest': 131072,
4547- 'ministral-8b-2410': 131072,
4548- 'ministral-8b-latest': 131072,
4549- 'mistral-large-2407': 131072,
4550- 'mistral-large-2411': 131072,
4551- 'mistral-large-latest': 131072,
4552- 'mistral-large-pixtral-2411': 131072,
4553- 'mistral-tiny-2407': 131072,
4554- 'mistral-tiny-latest': 131072,
4555- 'open-mistral-nemo': 131072,
4556- 'open-mistral-nemo-2407': 131072,
4557- 'pixtral-12b': 131072,
4558- 'pixtral-12b-2409': 131072,
4559- 'pixtral-12b-latest': 131072,
4560- 'pixtral-large-2411': 131072,
4561- 'pixtral-large-latest': 131072,
4562- 'open-mixtral-8x22b': 65536,
4563- 'open-mixtral-8x22b-2404': 65536,
4564- 'mistral-embed': 32768,
4565- 'mistral-large-2402': 32768,
4566- 'mistral-medium': 131072,
4567- 'mistral-medium-2312': 32768,
4568- 'mistral-medium-2505': 131072,
4569- 'mistral-medium-2508': 262144,
4570- 'mistral-medium-latest': 262144,
4571- 'mistral-moderation-2411': 32768,
4572- 'mistral-moderation-latest': 32768,
4573- 'mistral-ocr-2503': 32768,
4574- 'mistral-ocr-latest': 32768,
4575- 'mistral-saba-2502': 32768,
4576- 'mistral-saba-latest': 32768,
4577- 'mistral-small': 32768,
4578- 'mistral-small-2312': 32768,
4579- 'mistral-small-2402': 32768,
4580- 'mistral-small-2409': 32768,
4581- 'mistral-small-2501': 32768,
4582- 'mistral-small-2503': 32768,
4583- 'mistral-small-2506': 131072,
4584- 'mistral-small-latest': 131072,
4585- 'mistral-tiny': 32768,
4586- 'mistral-tiny-2312': 32768,
4587- 'open-mistral-7b': 32768,
4588- 'open-mixtral-8x7b': 32768,
4589- 'devstral-small-2505': 131072,
4590- 'devstral-small-2507': 131072,
4591- 'devstral-small-latest': 131072,
4592- 'devstral-medium-latest': 131072,
4593- 'devstral-medium-2507': 131072,
4594- 'magistral-medium-latest': 40960,
4595- 'magistral-medium-2506': 40960,
4596- 'magistral-small-latest': 40960,
4597- 'magistral-small-2506': 40000,
4598- 'magistral-small-2507': 40960,
4599- 'magistral-medium-2507': 40960,
4600- };
4601-
46024663 // Return context size if model found, otherwise default to 32k
4603- return Object.entries(contextMap).find(([key]) => model.includes(key))?.[1] || 32768;
4664+ return max_32k;
46044665}
46054666
46064667/**
@@ -4878,6 +4939,10 @@ async function onModelChange() {
48784939 }
48794940
48804941 if ($(this).is('#model_xai_select')) {
4942+ if (!value) {
4943+ console.debug('Null XAI model selected. Ignoring.');
4944+ return;
4945+ }
48814946 console.log('XAI model changed to', value);
48824947 oai_settings.xai_model = value;
48834948 }
@@ -4916,17 +4981,15 @@ async function onModelChange() {
49164981 if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(oai_settings.chat_completion_source)) {
49174982 if (oai_settings.max_context_unlocked) {
49184983 $('#openai_max_context').attr('max', max_2mil);
49194984 } else if (value.includes('gemini-12.5-proflash-image')) {
4920- $('#openai_max_context').attr('max', max_2mil);
4921- } else if (value.includes('gemini-2.5-flash-image-preview')) {
49224985 $('#openai_max_context').attr('max', max_32k);
49234986 } else if (value.includes('gemini-1.5-flash') || value.includes('gemini-2.0-flash') || value.includes('gemini-2.0-pro') || value.includes('gemini-exp') || value.includes('gemini-2.5-flash') || value.includes('gemini-2.5-pro') || value.includes('learnlm-2.0-flash') || value.includes('gemini-robotics')) {
49244987 $('#openai_max_context').attr('max', max_1mil);
49254988 } else if (value.includes('gemma-3-27b-it')) {
49264989 $('#openai_max_context').attr('max', max_128k);
49274990 } else if (value.includes('gemma-3n-e4b-it')) {
49284991 $('#openai_max_context').attr('max', max_8k);
49294992 } else if (value.includes('gemma-3') || value.includes('learnlm-1.5-pro-experimental')) {
49304993 $('#openai_max_context').attr('max', max_32k);
49314994 } else {
49324995 $('#openai_max_context').attr('max', max_32k);
@@ -4966,9 +5029,12 @@ async function onModelChange() {
49665029
49675030 if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) {
49685031 if (oai_settings.max_context_unlocked) {
49695032 $('#openai_max_context').attr('max', max_200kunlocked_max);
49705033 }
4971- else if (value == 'claude-2.1' || value.startsWith('claude-3') || value.startsWith('claude-opus') || value.startsWith('claude-sonnet')) {
5034+ else if (value.startsWith('claude-sonnet-4-5')) {
5035+ $('#openai_max_context').attr('max', max_1mil);
5036+ }
5037+ else if (value == 'claude-2.1' || value.startsWith('claude-3') || value.startsWith('claude-opus') || value.startsWith('claude-haiku') || value.startsWith('claude-sonnet')) {
49725038 $('#openai_max_context').attr('max', max_200k);
49735039 }
49745040 else if (value.endsWith('100k') || value.startsWith('claude-2') || value === 'claude-instant-1.2') {
@@ -5093,6 +5159,8 @@ async function onModelChange() {
50935159 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
50945160 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
50955161 $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
5162+
5163+ calculateElectronHubCost();
50965164 }
50975165
50985166 if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {
@@ -5144,11 +5212,14 @@ async function onModelChange() {
51445212 $('#openai_max_context').attr('max', unlocked_max);
51455213 } else if (oai_settings.xai_model.includes('grok-2-vision')) {
51465214 $('#openai_max_context').attr('max', max_32k);
51475215 } else if (oai_settings.xai_model.includes('grok-vision4-fast')) {
51485216 $('#openai_max_context').attr('max', max_8kmax_2mil);
51495217 } else if (oai_settings.xai_model.includes('grok-4')) {
51505218 $('#openai_max_context').attr('max', max_256k);
5219+ } else if (oai_settings.xai_model.includes('grok-code')) {
5220+ $('#openai_max_context').attr('max', max_256k);
51515221 } else {
5222+ // grok 2 and grok 3
51525223 $('#openai_max_context').attr('max', max_128k);
51535224 }
51545225
@@ -5220,6 +5291,10 @@ async function onOpenrouterModelSortChange() {
52205291 await getStatusOpen();
52215292}
52225293
5294+async function onElectronHubModelSortChange() {
5295+ await getStatusOpen();
5296+}
5297+
52235298async function onNewPresetClick() {
52245299 const name = await Popup.show.input(t`Preset name:`, t`Hint: Use a character/group name to bind preset to a specific chat.`, oai_settings.preset_settings_openai);
52255300
@@ -5677,11 +5752,11 @@ export function isImageInliningSupported() {
56775752 'c4ai-aya-vision',
56785753 'command-a-vision',
56795754 // Google AI Studio
5680- 'gemini-1.5',
56815755 'gemini-2.0',
56825756 'gemini-2.5',
56835757 'gemini-exp-1206',
56845758 'learnlm',
5759+ 'gemini-robotics',
56855760 // MistralAI
56865761 'mistral-small-2503',
56875762 'mistral-small-2506',
@@ -5693,7 +5768,6 @@ export function isImageInliningSupported() {
56935768 // xAI (Grok)
56945769 'grok-4',
56955770 'grok-2-vision',
5696- 'grok-vision',
56975771 // Moonshot
56985772 'moonshot-v1-8k-vision-preview',
56995773 'moonshot-v1-32k-vision-preview',
@@ -5726,6 +5800,7 @@ export function isImageInliningSupported() {
57265800 case chat_completion_sources.COHERE:
57275801 return visionSupportedModels.some(model => oai_settings.cohere_model.includes(model));
57285802 case chat_completion_sources.XAI:
5803+ // TODO: xAI's /models endpoint doesn't return modality info
57295804 return visionSupportedModels.some(model => oai_settings.xai_model.includes(model));
57305805 case chat_completion_sources.AIMLAPI:
57315806 return (Array.isArray(model_list) && model_list.find(m => m.id === oai_settings.aimlapi_model)?.features?.includes('openai/chat-completion.vision'));
@@ -6117,12 +6192,14 @@ export function initOpenAI() {
61176192 oai_settings.openai_max_context = Number($(this).val());
61186193 $('#openai_max_context_counter').val(`${$(this).val()}`);
61196194 calculateOpenRouterCost();
6195+ calculateElectronHubCost();
61206196 saveSettingsDebounced();
61216197 });
61226198
61236199 $('#openai_max_tokens').on('input', function () {
61246200 oai_settings.openai_max_tokens = Number($(this).val());
61256201 calculateOpenRouterCost();
6202+ calculateElectronHubCost();
61266203 saveSettingsDebounced();
61276204 });
61286205
@@ -6326,6 +6403,16 @@ export function initOpenAI() {
63266403 saveSettingsDebounced();
63276404 });
63286405
6406+ $('#electronhub_sort_models').on('input', function () {
6407+ oai_settings.electronhub_sort_models = String($(this).val());
6408+ saveSettingsDebounced();
6409+ });
6410+
6411+ $('#electronhub_group_models').on('input', function () {
6412+ oai_settings.electronhub_group_models = !!$(this).prop('checked');
6413+ saveSettingsDebounced();
6414+ });
6415+
63296416 $('#squash_system_messages').on('input', function () {
63306417 oai_settings.squash_system_messages = !!$(this).prop('checked');
63316418 saveSettingsDebounced();
@@ -6503,6 +6590,14 @@ export function initOpenAI() {
65036590 width: '100%',
65046591 templateResult: getAimlapiModelTemplate,
65056592 });
6593+ $('#model_electronhub_select').select2({
6594+ placeholder: t`Select a model`,
6595+ searchInputPlaceholder: t`Search models...`,
6596+ searchInputCssClass: 'text_pole',
6597+ width: '100%',
6598+ templateResult: getElectronHubModelTemplate,
6599+ matcher: textValueMatcher,
6600+ });
65066601 $('#completion_prompt_manager_popup_entry_form_injection_trigger').select2({
65076602 placeholder: t`All types (default)`,
65086603 width: '100%',
@@ -6549,6 +6644,8 @@ export function initOpenAI() {
65496644 $('#model_openrouter_select').on('change', onModelChange);
65506645 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);
65516646 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);
6647+ $('#electronhub_group_models').on('change', onElectronHubModelSortChange);
6648+ $('#electronhub_sort_models').on('change', onElectronHubModelSortChange);
65526649 $('#model_ai21_select').on('change', onModelChange);
65536650 $('#model_mistralai_select').on('change', onModelChange);
65546651 $('#model_cohere_select').on('change', onModelChange);
public/scripts/personas.js+39 -22
@@ -4,9 +4,11 @@ import {
44 characters,
55 chat,
66 chat_metadata,
7+ createOrEditCharacter,
78 default_user_avatar,
89 eventSource,
910 event_types,
11+ getCurrentChatId,
1012 getRequestHeaders,
1113 getThumbnailUrl,
1214 groupToEntity,
@@ -69,6 +71,9 @@ export let user_avatar = '';
6971/** @type {FilterHelper} Filter helper for the persona list */
7072export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
7173
74+/** @type {string} The last loaded chat id to remember for persona loading */
75+let personaLastLoadedChatId = null;
76+
7277/** @type {function(string): void} */
7378let navigateToAvatar = () => { };
7479
@@ -107,12 +112,16 @@ export function initUserAvatar(avatar) {
107112 * @param {boolean} [options.toastPersonaNameChange=true] Whether to show a toast when the persona name is changed
108113 * @param {boolean} [options.navigateToCurrent=false] Whether to navigate to the current persona after setting the avatar
109114 */
110115export async function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) {
116+ const currentUserAvatar = user_avatar;
111117 user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id');
118+ if (currentUserAvatar === user_avatar) {
119+ return;
120+ }
112121 reloadUserAvatar();
113122 updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });
114123 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
115124 await retriggerFirstMessageOnEmptyChat();
116125 saveSettingsDebounced();
117126 $('.zoomed_avatar[forchar]').remove();
118127}
@@ -732,13 +741,13 @@ export async function askForPersonaSelection(title, text, personas, { okButton =
732741/**
733742 * Automatically selects a persona based on the given name if a matching persona exists.
734743 * @param {string} name - The name to search for
735744 * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise
736745 */
737746export async function autoSelectPersona(name) {
738747 for (const [key, value] of Object.entries(power_user.personas)) {
739748 if (value === name) {
740749 console.log(`Auto-selecting persona ${key} for name ${name}`);
741750 await setUserAvatar(key);
742751 return true;
743752 }
744753 }
@@ -1131,7 +1140,7 @@ function onPersonaDescriptionInput() {
11311140 object.description = power_user.persona_description;
11321141 }
11331142
11341143 $(`.avatar-container[imgfiledata-avatar-id="${user_avatar}"] .ch_description`)
11351144 .text(power_user.persona_description || $('#user_avatar_block').attr('no_desc_text'))
11361145 .toggleClass('text_muted', !power_user.persona_description);
11371146 saveSettingsDebounced();
@@ -1219,7 +1228,7 @@ function onPersonaDescriptionPositionInput() {
12191228 $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
12201229}
12211230
12221231export function getOrCreatePersonaDescriptor() {
12231232 let object = power_user.persona_descriptions[user_avatar];
12241233
12251234 if (!object) {
@@ -1429,13 +1438,17 @@ function getPersonaTemporaryLockInfo() {
14291438 * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether a persona was selected
14301439 */
14311440async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1441+ const currentChatId = getCurrentChatId();
1442+ if (currentChatId === personaLastLoadedChatId) return;
1443+ personaLastLoadedChatId = currentChatId;
1444+
14321445 // Cache persona list to check if they exist
14331446 const userAvatars = await getUserAvatars(doRender);
14341447
14351448 // Check if the user avatar is set and exists in the list of user avatars
14361449 if (userAvatars.length && !userAvatars.includes(user_avatar)) {
14371450 console.log(`User avatar ${user_avatar} not found in user avatars list, pick the first available one`);
14381451 await setUserAvatar(userAvatars[0], { toastPersonaNameChange: false, navigateToCurrent: true });
14391452 }
14401453
14411454 // Define a persona for this chat
@@ -1530,7 +1543,7 @@ async function loadPersonaForCurrentChat({ doRender = false } = {}) {
15301543 // Persona avatar found, select it
15311544 if (chatPersona && user_avatar !== chatPersona) {
15321545 const willAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata['persona'];
15331546 await setUserAvatar(chatPersona, { toastPersonaNameChange: false, navigateToCurrent: true });
15341547
15351548 if (power_user.persona_show_notifications) {
15361549 let message = t`Auto-selected persona based on ${connectType} connection.<br />Your messages will now be sent as ${power_user.personas[chatPersona]}.`;
@@ -1542,7 +1555,7 @@ async function loadPersonaForCurrentChat({ doRender = false } = {}) {
15421555 }
15431556 // Even if it's the same persona, we still might need to auto-lock to chat if that's enabled
15441557 else if (chatPersona && power_user.persona_auto_lock && !chat_metadata['persona']) {
15451558 await lockPersona('chat');
15461559 }
15471560
15481561 updatePersonaUIStates();
@@ -1559,7 +1572,7 @@ async function loadPersonaForCurrentChat({ doRender = false } = {}) {
15591572export function getConnectedPersonas(characterKey = undefined) {
15601573 characterKey ??= selected_group || characters[Number(this_chid)]?.avatar;
15611574 const connectedPersonas = Object.entries(power_user.persona_descriptions)
15621575 .filter(([_, desc{ connections }]) => desc.connections?.some(conn => conn.type === 'character' && conn.id === characterKey))
15631576 .map(([key, _]) => key);
15641577 return connectedPersonas;
15651578}
@@ -1606,7 +1619,7 @@ export async function showCharConnections() {
16061619
16071620 // One of the persona was selected. So load it.
16081621 if (!isRemoving && selectedPersona) {
16091622 await setUserAvatar(selectedPersona, { toastPersonaNameChange: false });
16101623 if (power_user.persona_show_notifications) {
16111624 toastr.success(t`Selected persona ${power_user.personas[selectedPersona]} for current chat.`, t`Connected Persona Selected`);
16121625 }
@@ -1736,12 +1749,16 @@ async function syncUserNameToPersona() {
17361749
17371750/**
17381751 * Retriggers the first message to reload it from the char definition.
1739- *
1740- * Only works if only the first message is present, and not in group mode.
17411752 */
17421753export async function retriggerFirstMessageOnEmptyChat() {
1743- if (Number(this_chid) >= 0 && !selected_group && chat.length === 1) {
1754+ if (chat_metadata.tainted) {
1744- $('#firstmessage_textarea').trigger('input');
1755+ return;
1756+ }
1757+ if (selected_group) {
1758+ await reloadCurrentChat();
1759+ }
1760+ if (!selected_group && Number(this_chid) >= 0 && chat.length === 1) {
1761+ await createOrEditCharacter();
17451762 }
17461763}
17471764
@@ -1838,9 +1855,9 @@ async function lockPersonaCallback(_args, value) {
18381855 * Sets a persona name and optionally an avatar.
18391856 * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments
18401857 * @param {string} name Name to set
18411858 * @returns {Promise<string>}
18421859 */
18431860async function setNameCallback({ mode = 'all' }, name) {
18441861 if (!name) {
18451862 toastr.warning('You must specify a name to change to');
18461863 return '';
@@ -1858,7 +1875,7 @@ function setNameCallback({ mode = 'all' }, name) {
18581875 let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1];
18591876 if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];
18601877 if (persona) {
18611878 await autoSelectPersona(persona);
18621879 return '';
18631880 } else if (mode === 'lookup') {
18641881 toastr.warning(`Persona ${name} not found`);
@@ -2016,9 +2033,9 @@ export async function initPersonas() {
20162033 $('#sync_name_button').on('click', syncUserNameToPersona);
20172034 $('#avatar_upload_file').on('change', changeUserAvatar);
20182035
20192036 $(document).on('click', '#user_avatar_block .avatar-container', async function () {
20202037 const imgfile = $(this).attr('data-avatar-id');
20212038 await setUserAvatar(imgfile);
20222039 });
20232040
20242041 $('#persona_rename_button').on('click', () => renamePersona(user_avatar));
public/scripts/popup.js+18 -4
@@ -37,8 +37,8 @@ export const POPUP_RESULT = {
3737
3838/**
3939 * @typedef {object} PopupOptions
4040 * @property {string|boolean?} [okButton=null] - Custom text for the OK button,. orA set text will always show the button. `true` toor use`false` theto defaultexplicitly (Ifshow set,or hide the button. `null` will alwaysleave bethe displayed,behavior noand matterdisplay of the typebutton ofunchanged, based on the popup) type.
4141 * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button,. orA set text will always show the button. `true` toor use`false` theto defaultexplicitly (Ifshow set,or hide the button. `null` will alwaysleave bethe displayed,behavior noand matterdisplay of the typebutton ofunchanged, based on the popup) type.
4242 * @property {number?} [rows=1] - The number of rows for the input field
4343 * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)
4444 * @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling)
@@ -326,21 +326,31 @@ export class Popup {
326326
327327 switch (type) {
328328 case POPUP_TYPE.TEXT: {
329+ //Text shows OK if not explicitly set to false, and CANCEL only if defined as true or with a caption
330+ if (okButton === false) this.okButton.style.display = 'none';
329331 if (!cancelButton) this.cancelButton.style.display = 'none';
330332 break;
331333 }
332334 case POPUP_TYPE.CONFIRM: {
335+ // Confirm shows OK if not explicitly set to false, and CANCEL if not explicitly set to false
336+ if (okButton === false) this.okButton.style.display = 'none';
337+ if (cancelButton === false) this.cancelButton.style.display = 'none';
338+ // Override default captions for confirm on OK->Yes, CANCEL->No
333339 if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes');
334340 if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no');
335341 break;
336342 }
337343 case POPUP_TYPE.INPUT: {
338344 this.mainInput.style.display = 'block';
339- if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save');
345+ // Input shows OK if not explicitly set to false, and CANCEL if not explicitly set to false
346+ if (okButton === false) this.okButton.style.display = 'none';
340347 if (cancelButton === false) this.cancelButton.style.display = 'none';
348+ // Override default captions for input on OK->Save
349+ if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save');
341350 break;
342351 }
343352 case POPUP_TYPE.DISPLAY: {
353+ // Display hides OK and CANCEL and all main button controls
344354 this.buttonControls.style.display = 'none';
345355 this.closeButton.style.display = 'block';
346356 break;
@@ -348,7 +358,6 @@ export class Popup {
348358 case POPUP_TYPE.CROP: {
349359 this.cropWrap.style.display = 'block';
350360 this.cropImage.src = cropImage;
351- if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop');
352361 $(this.cropImage).cropper({
353362 aspectRatio: cropAspect ?? 2 / 3,
354363 autoCropArea: 1,
@@ -359,6 +368,11 @@ export class Popup {
359368 this.cropData.want_resize = !power_user.never_resize_avatars;
360369 },
361370 });
371+ // Crop shows OK if not explicitly set to false, and CANCEL if not explicitly set to false
372+ if (okButton === false) this.okButton.style.display = 'none';
373+ if (cancelButton === false) this.cancelButton.style.display = 'none';
374+ // Override default captions for crop on OK->Crop
375+ if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop');
362376 break;
363377 }
364378 default: {
public/scripts/power-user.js+8 -0
@@ -137,6 +137,7 @@ export const power_user = {
137137 streaming_fps: 30,
138138 smooth_streaming: false,
139139 smooth_streaming_speed: 50,
140+ stream_fade_in: false,
140141
141142 fast_ui_mode: true,
142143 avatar_style: avatar_styles.ROUND,
@@ -1685,6 +1686,8 @@ export async function loadPowerUserSettings(settings, data) {
16851686 $('#smooth_streaming').prop('checked', power_user.smooth_streaming);
16861687 $('#smooth_streaming_speed').val(power_user.smooth_streaming_speed);
16871688
1689+ $('#stream_fade_in').prop('checked', power_user.stream_fade_in);
1690+
16881691 $('#font_scale').val(power_user.font_scale);
16891692 $('#font_scale_counter').val(power_user.font_scale);
16901693
@@ -3493,6 +3496,11 @@ jQuery(() => {
34933496 saveSettingsDebounced();
34943497 });
34953498
3499+ $('#stream_fade_in').on('input', function () {
3500+ power_user.stream_fade_in = !!$(this).prop('checked');
3501+ saveSettingsDebounced();
3502+ });
3503+
34963504 $('input[name="font_scale"]').on('input', async function (e, data) {
34973505 const applyMode = data?.forced ? 'forced' : 'normal';
34983506 power_user.font_scale = Number($(this).val());
public/scripts/preset-manager.js+14 -10
@@ -81,6 +81,9 @@ function autoSelectPreset() {
8181 * @returns {PresetManager} Preset manager
8282 */
8383export function getPresetManager(apiId = '') {
84+ if (apiId === 'koboldhorde') {
85+ apiId = 'kobold';
86+ }
8487 if (!apiId) {
8588 apiId = main_api == 'koboldhorde' ? 'kobold' : main_api;
8689 }
@@ -814,7 +817,7 @@ class PresetManager {
814817 * Reads a preset extension field from the preset.
815818 * @param {object} options
816819 * @param {string} [options.name] Name of the preset. If not provided, uses the currently selected preset name.
817820 * @param {string} options.path Path to the preset extension field, e.g. 'myextension.data'. If empty, reads the entire extensions object.
818821 * @return {any} The value of the preset extension field, or null if not found.
819822 */
820823 readPresetExtensionField({ name, path }) {
@@ -825,7 +828,7 @@ class PresetManager {
825828 // Read from settings if the selected preset is the same as the provided name
826829 if (settings && selectedName === presetName) {
827830 const settingsExtensions = ensurePlainObject(settings.extensions || {});
828831 return path ? lodash.get(settingsExtensions, path, null) : settingsExtensions;
829832 }
830833
831834 // Otherwise, read from the preset by name
@@ -835,7 +838,7 @@ class PresetManager {
835838 }
836839
837840 const presetExtensions = ensurePlainObject(preset.extensions || {});
838841 const value = path ? lodash.get(presetExtensions, path, null) : presetExtensions;
839842 return value;
840843 }
841844
@@ -843,7 +846,7 @@ class PresetManager {
843846 * Writes a value to a preset extension field.
844847 * @param {object} options
845848 * @param {string} [options.name] Name of the preset. If not provided, uses the currently selected preset name.
846849 * @param {string} options.path Path to the preset extension field, e.g. 'myextension.data'. If empty, writes to the root of the extensions object.
847850 * @param {any} options.value Value to write to the preset extension field.
848851 * @return {Promise<void>} Resolves when the preset is saved.
849852 */
@@ -856,7 +859,7 @@ class PresetManager {
856859 if (settings && selectedName === presetName) {
857860 // Set the value at the specified path
858861 settings.extensions = ensurePlainObject(settings.extensions || {});
859862 path ? lodash.set(settings.extensions, path, value) : (settings.extensions = value);
860863 await saveSettings();
861864 }
862865
@@ -868,7 +871,7 @@ class PresetManager {
868871
869872 // Set the value at the specified path
870873 preset.extensions = ensurePlainObject(preset.extensions || {});
871874 path ? lodash.set(preset.extensions, path, value) : (preset.extensions = value);
872875
873876 // Save the updated preset
874877 await this.savePreset(presetName, preset, { skipUpdate: true });
@@ -1033,10 +1036,11 @@ export async function initPresetManager() {
10331036 return;
10341037 }
10351038
1039+ await eventSource.emit(event_types.PRESET_RENAMED_BEFORE, { apiId: apiId, oldName: oldName, newName: newName });
1040+ const extensions = presetManager.readPresetExtensionField({ name: oldName, path: '' });
10361041 await presetManager.renamePreset(newName);
1037-
1042+ await presetManager.writePresetExtensionField({ name: newName, path: '', value: extensions });
10381043 await eventSource.emit(event_types.PRESET_DELETEDPRESET_RENAMED, { apiId: apiId, nameoldName: oldName, newName: newName });
1039- await eventSource.emit(event_types.PRESET_CHANGED, { apiId: apiId, name: newName });
10401044
10411045 if (apiId === 'openai') {
10421046 // This is a horrible mess, but prevents the renamed preset from being corrupted.
@@ -1116,13 +1120,13 @@ export async function initPresetManager() {
11161120 if (result) {
11171121 const successToast = !presetManager.isAdvancedFormatting() ? t`Preset deleted` : t`Template deleted`;
11181122 toastr.success(successToast);
1123+ await eventSource.emit(event_types.PRESET_DELETED, { apiId, name });
11191124 } else {
11201125 const warningToast = !presetManager.isAdvancedFormatting() ? t`Preset was not deleted from server` : t`Template was not deleted from server`;
11211126 toastr.warning(warningToast);
11221127 }
11231128
11241129 saveSettingsDebounced();
1125- await eventSource.emit(event_types.PRESET_DELETED, { apiId: apiId, name: name });
11261130 });
11271131
11281132 $(document).on('click', '[data-preset-manager-restore]', async function () {
public/scripts/reasoning.js+9 -1
@@ -15,6 +15,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
1515import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
1616import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1717import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
18+import { applyStreamFadeIn } from './util/stream-fadein.js';
1819import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js';
1920
2021/**
@@ -124,6 +125,8 @@ export function extractReasoningFromData(data, {
124125 case chat_completion_sources.POLLINATIONS:
125126 case chat_completion_sources.MOONSHOT:
126127 case chat_completion_sources.COMETAPI:
128+ case chat_completion_sources.ELECTRONHUB:
129+ case chat_completion_sources.NANOGPT:
127130 case chat_completion_sources.CUSTOM: {
128131 return data?.choices?.[0]?.message?.reasoning_content
129132 ?? data?.choices?.[0]?.message?.reasoning
@@ -495,7 +498,12 @@ export class ReasoningHandler {
495498 // Update the reasoning message
496499 const reasoning = trimSpaces(this.reasoningDisplayText ?? this.reasoning);
497500 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
498- this.messageReasoningContentDom.innerHTML = displayReasoning;
501+
502+ if (power_user.stream_fade_in) {
503+ applyStreamFadeIn(this.messageReasoningContentDom, displayReasoning);
504+ } else {
505+ this.messageReasoningContentDom.innerHTML = displayReasoning;
506+ }
499507
500508 // Update tooltip for hidden reasoning edit
501509 /** @type {HTMLElement} */
public/scripts/secrets.js+26 -8
@@ -2,7 +2,7 @@ import { DOMPurify, moment } from '../lib.js';
22import { event_types, eventSource, getRequestHeaders } from '../script.js';
33import { t } from './i18n.js';
44import { chat_completion_sources } from './openai.js';
55import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
66import { SlashCommand } from './slash-commands/SlashCommand.js';
77import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
88import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -293,11 +293,13 @@ export let secret_state = {};
293293 * @param {string} key Secret key
294294 * @param {string} value Secret value to write
295295 * @param {string} [label] (Optional) Label for the key. If not provided, generated automatically.
296+ * @param {Object} [options] Additional options
297+ * @param {boolean} [options.allowEmpty] Whether to allow writing empty values. If false and value is empty, the secret will be deleted.
296298 * @return {Promise<string?>} The ID of the newly created secret key, or null if no value is provided.
297299 */
298300export async function writeSecret(key, value, label, { allowEmpty } = {}) {
299301 try {
300302 if (!value && !allowEmpty) {
301303 console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);
302304 await deleteSecret(key);
303305 return null;
@@ -558,7 +560,8 @@ async function openKeyManagerDialog(key) {
558560 const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));
559561 template.find('button[data-action="add-secret"]').on('click', async function () {
560562 let label = '';
561- const value = await Popup.show.input(t`Add Secret`, t`Enter the secret value:`, '', {
563+ let result = POPUP_RESULT.CANCELLED;
564+ const value = await Popup.show.input(t`Add Secret`, t`Enter the secret value (can be empty):`, '', {
562565 customInputs: [{
563566 id: 'newSecretLabel',
564567 type: 'text',
@@ -567,13 +570,20 @@ async function openKeyManagerDialog(key) {
567570 onClose: popup => {
568571 if (popup.result) {
569572 label = popup.inputResults.get('newSecretLabel').toString().trim();
573+ result = popup.result;
570574 }
571575 },
572576 });
573577 if (!value) {
574- return;
578+ if (result !== POPUP_RESULT.AFFIRMATIVE) {
579+ return;
580+ }
581+ const allowEmpty = await Popup.show.confirm(t`No value entered`, t`No value was entered for the secret. Do you want to add an empty secret?`);
582+ if (!allowEmpty) {
583+ return;
584+ }
575585 }
576586 await writeSecret(key, value, label, { allowEmpty: true });
577587 await renderSecretsList();
578588 });
579589
@@ -821,6 +831,13 @@ function registerSecretSlashCommands() {
821831 isRequired: false,
822832 typeList: [ARGUMENT_TYPE.STRING],
823833 }),
834+ SlashCommandNamedArgument.fromProps({
835+ name: 'empty',
836+ description: t`Whether to allow empty values.`,
837+ isRequired: false,
838+ typeList: [ARGUMENT_TYPE.BOOLEAN],
839+ defaultValue: String(false),
840+ }),
824841 ],
825842 unnamedArgumentList: [
826843 SlashCommandArgument.fromProps({
@@ -831,6 +848,7 @@ function registerSecretSlashCommands() {
831848 ],
832849 callback: async (args, value) => {
833850 const quiet = isTrueBoolean(args?.quiet?.toString());
851+ const allowEmpty = isTrueBoolean(args?.empty?.toString());
834852 const key = args?.key?.toString()?.trim() || resolveSecretKey();
835853
836854 if (!key) {
@@ -849,7 +867,7 @@ function registerSecretSlashCommands() {
849867 }
850868
851869 const valueStr = value?.toString()?.trim();
852870 if (!valueStr && !allowEmpty) {
853871 if (!quiet) {
854872 toastr.error(t`No value provided for the secret key: ${key}`);
855873 }
@@ -857,7 +875,7 @@ function registerSecretSlashCommands() {
857875 }
858876
859877 const label = args?.label?.toString()?.trim() || getLabel();
860878 const id = await writeSecret(key, valueStr, label, { allowEmpty });
861879
862880 if (!quiet) {
863881 toastr.success(t`Secret has been written for the key: ${key}`);
public/scripts/slash-commands.js+8 -0
@@ -4585,6 +4585,8 @@ export async function sendMessageAs(args, text) {
45854585 insertAt = chat.length + insertAt;
45864586 }
45874587
4588+ chat_metadata['tainted'] = true;
4589+
45884590 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {
45894591 chat.splice(insertAt, 0, message);
45904592 await saveChatConditional();
@@ -4635,6 +4637,8 @@ export async function sendNarratorMessage(args, text) {
46354637 insertAt = chat.length + insertAt;
46364638 }
46374639
4640+ chat_metadata['tainted'] = true;
4641+
46384642 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {
46394643 chat.splice(insertAt, 0, message);
46404644 await saveChatConditional();
@@ -4685,6 +4689,8 @@ export async function promptQuietForLoudResponse(who, text) {
46854689 },
46864690 };
46874691
4692+ chat_metadata['tainted'] = true;
4693+
46884694 chat.push(message);
46894695 await eventSource.emit(event_types.MESSAGE_SENT, (chat.length - 1));
46904696 addOneMessage(message);
@@ -4719,6 +4725,8 @@ async function sendCommentMessage(args, text) {
47194725 insertAt = chat.length + insertAt;
47204726 }
47214727
4728+ chat_metadata['tainted'] = true;
4729+
47224730 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {
47234731 chat.splice(insertAt, 0, message);
47244732 await saveChatConditional();
public/scripts/st-context.js+2 -0
@@ -57,6 +57,7 @@ import {
5757import {
5858 extension_settings,
5959 ModuleWorkerWrapper,
60+ openThirdPartyExtensionMenu,
6061 renderExtensionTemplate,
6162 renderExtensionTemplateAsync,
6263 saveMetadataDebounced,
@@ -233,6 +234,7 @@ export function getContext() {
233234 parseReasoningFromString,
234235 unshallowCharacter,
235236 unshallowGroupMembers,
237+ openThirdPartyExtensionMenu,
236238 symbols: {
237239 ignore: IGNORE_SYMBOL,
238240 },
public/scripts/tags.js+5 -0
@@ -765,8 +765,13 @@ async function importTags(character, { importSetting = null } = {}) {
765765 */
766766async function handleTagImport(character, { importSetting = null } = {}) {
767767 /** @type {string[]} */
768+ const alreadyAssignedTags = tag_map[character.avatar] ?? [];
768769 const importTags = character.tags.map(t => t.trim()).filter(t => t)
769770 .filter(t => !IMPORT_EXLCUDED_TAGS.includes(t))
771+ .filter(t => {
772+ const existingTag = getTag(t);
773+ return !existingTag || !alreadyAssignedTags.includes(existingTag.id);
774+ })
770775 .slice(0, ANTI_TROLL_MAX_TAGS);
771776 const existingTags = getExistingTags(importTags);
772777 const newTags = importTags.filter(t => !existingTags.some(existingTag => existingTag.name.toLowerCase() === t.toLowerCase()))
public/scripts/templates/macros.html+2 -0
@@ -22,8 +22,10 @@
2222 <li><tt>&lcub;&lcub;char&rcub;&rcub;</tt> – <span data-i18n="help_macros_16">the Character's name</span></li>
2323 <li><tt>&lcub;&lcub;version&rcub;&rcub;</tt> – <span data-i18n="help_macros_17">the Character's version number</span></li>
2424 <li><tt>&lcub;&lcub;charDepthPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_charDepthPrompt">the Character's @ Depth Note</span></li>
25+ <li><tt>&lcub;&lcub;outlet::(name)&rcub;&rcub;</tt> – <span data-i18n="help_macros_outletName">the WI entry content for the outlet with the specified name</span></li>
2526 <li><tt>&lcub;&lcub;group&rcub;&rcub;</tt> – <span data-i18n="help_macros_18">a comma-separated list of group member names (including muted) or the character name in solo chats. Alias: &lcub;&lcub;charIfNotGroup&rcub;&rcub;</span></li>
2627 <li><tt>&lcub;&lcub;groupNotMuted&rcub;&rcub;</tt> – <span data-i18n="help_groupNotMuted">the same as &lcub;&lcub;group&rcub;&rcub;, but excludes muted members</span></li>
28+ <li><tt>&lcub;&lcub;notChar&rcub;&rcub;</tt> – <span data-i18n="help_notChar">a comma-separated list of all participants in the conversation except for the current speaker (&lcub;&lcub;char&rcub;&rcub;). In group chats, this includes muted characters. When not in a generation, the list include all characters.</span></li>
2729 <li><tt>&lcub;&lcub;model&rcub;&rcub;</tt> – <span data-i18n="help_macros_19">a text generation model name for the currently selected API. </span><b data-i18n="Can be inaccurate!">Can be inaccurate!</b></li>
2830 <li><tt>&lcub;&lcub;lastMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_20">the text of the latest chat message.</span></li>
2931 <li><tt>&lcub;&lcub;lastUserMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_lastUser">the text of the latest user chat message.</span></li>
public/scripts/tokenizers.js+51 -0
@@ -648,6 +648,57 @@ export function getTokenizerModel() {
648648 }
649649 }
650650
651+ if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB && oai_settings.electronhub_model) {
652+ if (oai_settings.electronhub_model.includes('gpt-4o') || oai_settings.electronhub_model.includes('gpt-5')) {
653+ return gpt4oTokenizer;
654+ }
655+ else if (oai_settings.electronhub_model.includes('gpt-4.1') || oai_settings.electronhub_model.includes('gpt-4.5')) {
656+ return gpt4oTokenizer;
657+ }
658+ else if (oai_settings.electronhub_model.includes('gpt-4')) {
659+ return gpt4Tokenizer;
660+ }
661+ else if (oai_settings.electronhub_model.includes('gpt-3.5-turbo')) {
662+ return turboTokenizer;
663+ }
664+ else if (oai_settings.electronhub_model.includes('claude')) {
665+ return claudeTokenizer;
666+ }
667+ else if (oai_settings.electronhub_model.includes('jamba')) {
668+ return jambaTokenizer;
669+ }
670+ else if (oai_settings.electronhub_model.includes('deepseek') || oai_settings.electronhub_model.includes('sonar-reasoning') || oai_settings.electronhub_model.includes('r1')) {
671+ return deepseekTokenizer;
672+ }
673+ else if (oai_settings.electronhub_model.includes('qwen')) {
674+ return qwen2Tokenizer;
675+ }
676+ else if (oai_settings.electronhub_model.includes('gemma')) {
677+ return gemmaTokenizer;
678+ }
679+ else if (oai_settings.electronhub_model.includes('mistral')) {
680+ return mistralTokenizer;
681+ }
682+ else if (oai_settings.electronhub_model.includes('yi')) {
683+ return yiTokenizer;
684+ }
685+ else if (oai_settings.electronhub_model.includes('llama3') || oai_settings.electronhub_model.includes('llama-3') || oai_settings.electronhub_model.startsWith('l3')) {
686+ return llama3Tokenizer;
687+ }
688+ else if (oai_settings.electronhub_model.includes('llama')) {
689+ return llamaTokenizer;
690+ }
691+ else if (oai_settings.electronhub_model.includes('command-a')) {
692+ return commandATokenizer;
693+ }
694+ else if (oai_settings.electronhub_model.includes('command-r')) {
695+ return commandRTokenizer;
696+ }
697+ else if (oai_settings.electronhub_model.includes('nemo')) {
698+ return nemoTokenizer;
699+ }
700+ }
701+
651702 if (oai_settings.chat_completion_source == chat_completion_sources.COHERE) {
652703 if (oai_settings.cohere_model.includes('command-a')) {
653704 return commandATokenizer;
public/scripts/tool-calling.js+3 -2
@@ -526,12 +526,13 @@ export class ToolManager {
526526 for (let choiceIndex = 0; choiceIndex < parsed.candidates.length; choiceIndex++) {
527527 const candidate = parsed.candidates[choiceIndex];
528528 if (Array.isArray(candidate?.content?.parts)) {
529529 for (let toolCallIndexpartIndex = 0; toolCallIndexpartIndex < candidate.content.parts.length; toolCallIndexpartIndex++) {
530530 const part = candidate.content.parts[toolCallIndexpartIndex];
531531 if (part.functionCall) {
532532 if (!Array.isArray(toolCalls[choiceIndex])) {
533533 toolCalls[choiceIndex] = [];
534534 }
535+ const toolCallIndex = toolCalls[choiceIndex].length;
535536 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
536537 toolCalls[choiceIndex][toolCallIndex] = {};
537538 }
public/scripts/util/stream-fadein.js+69 -0
@@ -0,0 +1,69 @@
1+import { morphdom } from '../../lib.js';
2+
3+/**
4+ * Check if the current browser supports native segmentation function.
5+ * @returns {boolean} True if the Segmenter is supported by the current browser.
6+ */
7+export function isSegmenterSupported() {
8+ return typeof Intl.Segmenter === 'function';
9+}
10+
11+/**
12+ * Segment text in the given HTML content using Intl.Segmenter.
13+ * @param {HTMLElement} htmlElement Target HTML element
14+ * @param {string} htmlContent HTML content to segment
15+ * @param {'word'|'grapheme'|'sentence'} [granularity='word'] Text split granularity
16+ */
17+export function segmentTextInElement(htmlElement, htmlContent, granularity = 'word') {
18+ htmlElement.innerHTML = htmlContent;
19+
20+ if (!isSegmenterSupported()) {
21+ return;
22+ }
23+
24+ // TODO: Support more locales, make granularity configurable.
25+ const segmenter = new Intl.Segmenter('en-US', { granularity });
26+ const textNodes = [];
27+ const walker = document.createTreeWalker(htmlElement, NodeFilter.SHOW_TEXT);
28+ while (walker.nextNode()) {
29+ const textNode = /** @type {Text} */ (walker.currentNode);
30+
31+ // Skip ancestors of code/pre
32+ if (textNode.parentElement && textNode.parentElement.closest('pre, code')) {
33+ continue;
34+ }
35+
36+ // Skip text nodes that are empty or only whitespace
37+ if (/^\s*$/.test(textNode.data)) {
38+ continue;
39+ }
40+
41+ textNodes.push(textNode);
42+ }
43+
44+ // Split every text node into segments using spans
45+ for (const textNode of textNodes) {
46+ const fragment = document.createDocumentFragment();
47+ const segments = segmenter.segment(textNode.data);
48+ for (const segment of segments) {
49+ // TODO: Apply a different class for different segment length/content?
50+ // For now, just use a single class for all segments.
51+ const span = document.createElement('span');
52+ span.innerText = segment.segment;
53+ span.className = 'text_segment';
54+ fragment.appendChild(span);
55+ }
56+ textNode.replaceWith(fragment);
57+ }
58+}
59+
60+/**
61+ * Apply stream fade-in effect to the given message text element by morphing its content.
62+ * @param {HTMLElement} messageTextElement Message text element
63+ * @param {string} htmlContent New HTML content to apply
64+ */
65+export function applyStreamFadeIn(messageTextElement, htmlContent) {
66+ const targetElement = /** @type {HTMLElement} */ (messageTextElement.cloneNode());
67+ segmentTextInElement(targetElement, htmlContent);
68+ morphdom(messageTextElement, targetElement);
69+}
public/scripts/utils.js+132 -4
@@ -3,10 +3,11 @@ import {
33 DOMPurify,
44 Readability,
55 isProbablyReaderable,
6+ lodash,
67} from '../lib.js';
78
89import { getContext } from './extensions.js';
910import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js';
1011import { isMobile } from './RossAscends-mods.js';
1112import { collapseNewlines, power_user } from './power-user.js';
1213import { debounce_timeout } from './constants.js';
@@ -15,6 +16,10 @@ import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
1516import { getTagsList } from './tags.js';
1617import { groups, selected_group } from './group-chats.js';
1718import { getCurrentLocale, t } from './i18n.js';
19+import { importWorldInfo } from './world-info.js';
20+
21+export const shiftUpByOne = (e, i, a) => a[i] = e + 1;
22+export const shiftDownByOne = (e, i, a) => a[i] = e - 1;
1823
1924/**
2025 * Pagination status string template.
@@ -25,6 +30,8 @@ export const PAGINATION_TEMPLATE = '<%= rangeStart %>-<%= rangeEnd %> .. <%= tot
2530export const localizePagination = function(container) {
2631 container.find('[title="Next page"]').attr('title', t`Next page`);
2732 container.find('[title="Previous page"]').attr('title', t`Previous page`);
33+ container.find('[title="First page"]').attr('title', t`First page`);
34+ container.find('[title="Last page"]').attr('title', t`Last page`);
2835};
2936
3037/**
@@ -285,6 +292,15 @@ export function removeFromArray(array, item) {
285292}
286293
287294/**
295+ * Normalizes an array by removing duplicates, trimming strings, and filtering out empty values.
296+ * @param {any[]} arr - The array to normalize.
297+ * @returns {any[]} The normalized array.
298+ */
299+export function normalizeArray(arr) {
300+ return [...new Set((arr ?? []).map(s => typeof s === 'string' ? s.trim() : s).filter(Boolean))];
301+}
302+
303+/**
288304 * Checks if a string only contains digits.
289305 * @param {string} str The string to check.
290306 * @returns {boolean} True if the string only contains digits, false otherwise.
@@ -616,7 +632,7 @@ export function isElementInViewport(el) {
616632/**
617633 * Returns a name that is unique among the names that exist.
618634 * @param {string} name The name to check.
619635 * @param {{ (yname: anystring): boolean; }} exists Function to check if name exists.
620636 * @returns {string} A unique name.
621637 */
622638export function getUniqueName(name, exists) {
@@ -1633,13 +1649,18 @@ export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type
16331649 * @param {{ (): boolean; }} condition The condition to wait for.
16341650 * @param {number} [timeout=1000] The timeout in milliseconds.
16351651 * @param {number} [interval=100] The interval in milliseconds.
1652+ * @param {object} [options] Options object
1653+ * @param {boolean} [options.rejectOnTimeout=true] Whether to reject the promise on timeout or resolve it.
16361654 * @returns {Promise<void>} A promise that resolves when the condition is true.
16371655 */
16381656export async function waitUntilCondition(condition, timeout = 1000, interval = 100, options = {}) {
1657+ const { rejectOnTimeout = true } = options;
1658+
16391659 return new Promise((resolve, reject) => {
16401660 const timeoutId = setTimeout(() => {
16411661 clearInterval(intervalId);
1642- reject(new Error('Timed out waiting for condition to be true'));
1662+ const timeoutFn = rejectOnTimeout ? reject : resolve;
1663+ timeoutFn(new Error('Timed out waiting for condition to be true'));
16431664 }, timeout);
16441665
16451666 const intervalId = setInterval(() => {
@@ -2560,3 +2581,110 @@ export function textValueMatcher(params, data) {
25602581export function versionCompare(srcVersion, minVersion) {
25612582 return (srcVersion || '0.0.0').localeCompare(minVersion, undefined, { numeric: true, sensitivity: 'base' }) > -1;
25622583}
2584+
2585+/**
2586+ * Sets up the scroll-to-top button functionality.
2587+ * @param {object} params Parameters object
2588+ * @param {string} params.scrollContainerId Scrollable container element ID
2589+ * @param {string} params.buttonId Button element ID
2590+ * @param {string} params.drawerId Drawer element ID
2591+ * @param {number} [params.visibilityThreshold] Scroll position (px) to show the button (default: 300)
2592+ * @returns {() => void} Cleanup function to remove event listeners
2593+ */
2594+export function setupScrollToTop({ scrollContainerId, buttonId, drawerId, visibilityThreshold = 300 }) {
2595+ const scrollContainer = document.getElementById(scrollContainerId);
2596+ const btn = document.getElementById(buttonId);
2597+ const drawer = document.getElementById(drawerId);
2598+
2599+ if (!btn || !drawer) {
2600+ // Not fatal; the drawer or button may not exist in some builds. Use debug level.
2601+ console.debug('Scroll-to-top: button or drawer not found during setup.');
2602+ return () => { /* noop cleanup */ };
2603+ }
2604+
2605+ if (!scrollContainer) {
2606+ console.debug('Scroll-to-top: scroll container not found during setup.');
2607+ return () => { /* noop cleanup */ };
2608+ }
2609+
2610+ const updateButtonVisibility = () => btn.classList.toggle('visible', scrollContainer.scrollTop > visibilityThreshold);
2611+ const updateButtonVisibilityThrottled = lodash.throttle(updateButtonVisibility, debounce_timeout.standard, { leading: true, trailing: true });
2612+ const onScroll = () => updateButtonVisibilityThrottled();
2613+ scrollContainer.addEventListener('scroll', onScroll, { passive: true });
2614+
2615+ // Scroll to top on click (button semantics provide keyboard activation natively)
2616+ const onActivate = (/** @type {MouseEvent} */ e) => {
2617+ e.preventDefault();
2618+ e.stopPropagation();
2619+
2620+ const userPrefersReduced = power_user.reduced_motion;
2621+ scrollContainer.scrollTo({ top: 0, behavior: userPrefersReduced ? 'auto' : 'smooth' });
2622+ };
2623+ btn.addEventListener('click', onActivate);
2624+
2625+ // Initial state check
2626+ updateButtonVisibility();
2627+
2628+ // Return cleanup function for caller to hold and invoke when appropriate
2629+ return () => {
2630+ scrollContainer.removeEventListener('scroll', onScroll);
2631+ btn.removeEventListener('click', onActivate);
2632+ };
2633+}
2634+
2635+/**
2636+ * Imports content from an external URL.
2637+ * @param {string} url URL or UUID of the content to import.
2638+ * @param {Object} [options={}] Options object.
2639+ * @param {string|null} [options.preserveFileName=null] Optional file name to use for the imported content.
2640+ * @returns {Promise<void>} A promise that resolves when the import is complete.
2641+ */
2642+export async function importFromExternalUrl(url, { preserveFileName = null } = {}) {
2643+ let request;
2644+
2645+ if (isValidUrl(url)) {
2646+ console.debug('Custom content import started for URL: ', url);
2647+ request = await fetch('/api/content/importURL', {
2648+ method: 'POST',
2649+ headers: getRequestHeaders(),
2650+ body: JSON.stringify({ url }),
2651+ });
2652+ } else {
2653+ console.debug('Custom content import started for Char UUID: ', url);
2654+ request = await fetch('/api/content/importUUID', {
2655+ method: 'POST',
2656+ headers: getRequestHeaders(),
2657+ body: JSON.stringify({ url }),
2658+ });
2659+ }
2660+
2661+ if (!request.ok) {
2662+ toastr.info(request.statusText, 'Custom content import failed');
2663+ console.error('Custom content import failed', request.status, request.statusText);
2664+ return;
2665+ }
2666+
2667+ const data = await request.blob();
2668+ const customContentType = request.headers.get('X-Custom-Content-Type');
2669+ let fileName = request.headers.get('Content-Disposition').split('filename=')[1].replace(/"/g, '');
2670+ const file = new File([data], fileName, { type: data.type });
2671+
2672+ const extraData = new Map();
2673+ if (preserveFileName) {
2674+ fileName = preserveFileName;
2675+ extraData.set(file, preserveFileName);
2676+ }
2677+
2678+ switch (customContentType) {
2679+ case 'character':
2680+ await processDroppedFiles([file], extraData);
2681+ break;
2682+ case 'lorebook':
2683+ await importWorldInfo(file);
2684+ break;
2685+ default:
2686+ toastr.warning('Unknown content type');
2687+ console.error('Unknown content type', customContentType);
2688+ break;
2689+ }
2690+}
public/scripts/world-info.js+324 -64
@@ -1,7 +1,7 @@
11import { Fuse } from '../lib.js';
22
33import { saveSettings, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles, create_save, createOrEditCharacter, name1 } from '../script.js';
44import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce, findChar, onlyUnique, equalsIgnoreCaseAndAccents, uuidv4, normalizeArray, getUniqueName } from './utils.js';
55import { extension_settings, getContext } from './extensions.js';
66import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
77import { isMobile } from './RossAscends-mods.js';
@@ -22,6 +22,7 @@ import { StructuredCloneMap } from './util/StructuredCloneMap.js';
2222import { renderTemplateAsync } from './templates.js';
2323import { t } from './i18n.js';
2424import { accountStorage } from './util/AccountStorage.js';
25+import { getOrCreatePersonaDescriptor, setPersonaDescription, user_avatar } from './personas.js';
2526
2627export const world_info_insertion_strategy = {
2728 evenly: 0,
@@ -156,6 +157,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
156157 * @property {Array} worldInfoDepth - Array of depth entries
157158 * @property {Array} anBefore - Array of entries before Author's Note
158159 * @property {Array} anAfter - Array of entries after Author's Note
160+ * @property {{[key: string]: string[]}} outletEntries - Array of entries to be added to an outlet
159161 */
160162
161163/**
@@ -166,6 +168,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
166168 * @property {any[]} WIDepthEntries The depth entries.
167169 * @property {any[]} ANBeforeEntries The entries before Author's Note.
168170 * @property {any[]} ANAfterEntries The entries after Author's Note.
171+ * @property {{[key: string]: string[]}} outletEntries - Array of entries to be added to an outlet
169172 * @property {Set<any>} allActivatedEntries All entries.
170173 */
171174
@@ -817,6 +820,7 @@ export const world_info_position = {
817820 atDepth: 4,
818821 EMTop: 5,
819822 EMBottom: 6,
823+ outlet: 7,
820824};
821825
822826export const wi_anchor_position = {
@@ -866,6 +870,7 @@ export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanD
866870 worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],
867871 anBefore: activatedWorldInfo.ANBeforeEntries ?? [],
868872 anAfter: activatedWorldInfo.ANAfterEntries ?? [],
873+ outletEntries: activatedWorldInfo.outletEntries ?? {},
869874 };
870875}
871876
@@ -1037,39 +1042,69 @@ function registerWorldInfoSlashCommands() {
10371042
10381043 /**
10391044 * Gets the name of the persona-bound lorebook.
1040- * @returns {string} The name of the persona-bound lorebook
1045+ * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments
1046+ * @param {string} _unnamedArg not used
1047+ * @returns {Promise<string>} The name of the persona-bound lorebook
10411048 */
10421049 async function getPersonaBookCallback({ name, create }, _unnamedArg) {
10431050 returnlet bookName = power_user.persona_description_lorebook || '';
1051+ if (bookName) {
1052+ return bookName;
1053+ }
1054+
1055+ if (isTrueBoolean(String(create))) {
1056+ const newName = await createWorldWithName(name, `Persona Book ${name1}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64));
1057+ power_user.persona_description_lorebook = newName;
1058+ setPersonaDescription();
1059+ saveSettingsDebounced();
1060+ return newName;
1061+ }
1062+
1063+ return '';
10441064 }
10451065
10461066 /**
10471067 * Gets the name of the character-bound lorebook.
10481068 * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments
10491069 * @param {string} namecharacterIdentifier Character name
10501070 * @returns {Promise<string>} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string
10511071 */
10521072 async function getCharBookCallback({ type, name, create }, namecharacterIdentifier) {
10531073 const context = getContext();
10541074 if (context.groupId && !namecharacterIdentifier) throw new Error('This command is not available in groups without providing a character name');
10551075 type = String(type ?? '').trim().toLowerCase() || 'primary';
10561076 namecharacterIdentifier = String(namecharacterIdentifier ?? '') || context.characters[context.characterId]?.avatar || null;
10571077 const character = findChar({ name: characterIdentifier });
10581078 if (!character) {
10591079 toastr.error(t`Character not found.`);
10601080 return '';
10611081 }
10621082 const books = [];
10631083 if (type === 'all' || type === 'primary' && character.data?.extensions?.world) {
10641084 books.push(character.data?.extensions?.world);
10651085 }
10661086 if (type === 'all' || type === 'additional') {
10671087 const fileName = getCharaFilename(context.characters.indexOf(character));
10681088 const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
10691089 if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) {
10701090 books.push(...extraCharLore.extraBooks.filter(onlyUnique).filter(Boolean));
10711091 }
10721092 }
1093+
1094+ if (isTrueBoolean(String(create)) && books.length === 0) {
1095+ const newName = await createWorldWithName(name, `Character Book ${character.name}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64));
1096+ // Also assign the book now - additional if requested, otherwise as primary
1097+ if (type === 'additional') {
1098+ await charUpdateAddAuxWorld(character.avatar, newName);
1099+ }
1100+ else {
1101+ await charUpdatePrimaryWorld(newName);
1102+ }
1103+ // Refresh UI, if needed
1104+ setWorldInfoButtonClass(this_chid);
1105+ books.push(newName);
1106+ }
1107+
10731108 return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean));
10741109 }
10751110
@@ -1090,10 +1125,23 @@ function registerWorldInfoSlashCommands() {
10901125 return chat_metadata[METADATA_KEY];
10911126 }
10921127
1093- const name = (() => {
1128+ if (isFalseBoolean(String(args.create))) {
1129+ return '';
1130+ }
1131+
1132+ const name = await createWorldWithName(args.name, `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64));
1133+
1134+ chat_metadata[METADATA_KEY] = name;
1135+ await saveMetadata();
1136+ $('.chat_lorebook_button').addClass('world_set');
1137+ return name;
1138+ }
1139+
1140+ async function createWorldWithName(possibleName = undefined, fallbackName = undefined) {
1141+ let newName = (() => {
10941142 // Use the provided name if it's not in use
10951143 if (typeof args.namepossibleName === 'string') {
10961144 const name = String(args.namepossibleName);
10971145 if (world_names.includes(name)) {
10981146 throw new Error('This World Info file name is already in use');
10991147 }
@@ -1101,14 +1149,14 @@ function registerWorldInfoSlashCommands() {
11011149 }
11021150
11031151 // Replace non-alphanumeric characters with underscores, cut to 64 characters
1104- return `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64);
1152+ return fallbackName ?? `Lorebook (${uuidv4()})`;
11051153 })();
1106- await createNewWorldInfo(name);
11071154
1108- chat_metadata[METADATA_KEY] = name;
1155+ // Make sure the name is unique
1109- await saveMetadata();
1156+ newName = getUniqueName(newName, world_names.includes.bind(world_names));
1110- $('.chat_lorebook_button').addClass('world_set');
1157+
1111- return name;
1158+ await createNewWorldInfo(newName);
1159+ return newName;
11121160 }
11131161
11141162 async function findBookEntryCallback(args, value) {
@@ -1545,6 +1593,15 @@ function registerWorldInfoSlashCommands() {
15451593 isRequired: false,
15461594 acceptsMultiple: false,
15471595 }),
1596+ SlashCommandNamedArgument.fromProps({
1597+ name: 'create',
1598+ description: 'create a new lorebook if it doesn\'t exist',
1599+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1600+ isRequired: false,
1601+ acceptsMultiple: false,
1602+ enumList: commonEnumProviders.boolean('trueFalse')(),
1603+ defaultValue: 'true',
1604+ }),
15481605 ],
15491606 aliases: ['getchatlore', 'getchatwi'],
15501607 }));
@@ -1559,6 +1616,25 @@ function registerWorldInfoSlashCommands() {
15591616 name: 'getpersonabook',
15601617 callback: getPersonaBookCallback,
15611618 returns: 'lorebook name',
1619+
1620+ namedArgumentList: [
1621+ SlashCommandNamedArgument.fromProps({
1622+ name: 'name',
1623+ description: 'lorebook name if creating a new one, will be auto-generated otherwise',
1624+ typeList: [ARGUMENT_TYPE.STRING],
1625+ isRequired: false,
1626+ acceptsMultiple: false,
1627+ }),
1628+ SlashCommandNamedArgument.fromProps({
1629+ name: 'create',
1630+ description: 'create a new lorebook if it doesn\'t exist',
1631+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1632+ isRequired: false,
1633+ acceptsMultiple: false,
1634+ enumList: commonEnumProviders.boolean('trueFalse')(),
1635+ defaultValue: 'false',
1636+ }),
1637+ ],
15621638 helpString: 'Get a name of the current persona-bound lorebook and pass it down the pipe. Returns empty string if persona lorebook is not set.',
15631639 aliases: ['getpersonalore', 'getpersonawi'],
15641640 }));
@@ -1574,6 +1650,22 @@ function registerWorldInfoSlashCommands() {
15741650 enumList: ['primary', 'additional', 'all'],
15751651 defaultValue: 'primary',
15761652 }),
1653+ SlashCommandNamedArgument.fromProps({
1654+ name: 'name',
1655+ description: 'lorebook name if creating a new one, will be auto-generated otherwise',
1656+ typeList: [ARGUMENT_TYPE.STRING],
1657+ isRequired: false,
1658+ acceptsMultiple: false,
1659+ }),
1660+ SlashCommandNamedArgument.fromProps({
1661+ name: 'create',
1662+ description: 'create a new lorebook if it doesn\'t exist',
1663+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1664+ isRequired: false,
1665+ acceptsMultiple: false,
1666+ enumList: commonEnumProviders.boolean('trueFalse')(),
1667+ defaultValue: 'false',
1668+ }),
15771669 ],
15781670 unnamedArgumentList: [
15791671 SlashCommandArgument.fromProps({
@@ -3425,6 +3517,19 @@ export async function getWorldEntry(name, data, entry) {
34253517 contentInput.val(entry.content).trigger('input', { skipCount: true, noSave: true });
34263518 editTemplate.find('.editor_maximize').attr('data-for', contentInputId);
34273519
3520+ // Outlet name
3521+ const outletNameInput = editTemplate.find('input[name="outletName"]');
3522+ outletNameInput.data('uid', entry.uid);
3523+ outletNameInput.on('input', async function (_, { noSave = false } = {}) {
3524+ const uid = $(this).data('uid');
3525+ const value = $(this).val();
3526+ data.entries[uid].outletName = value;
3527+ setWIOriginalDataValue(data, uid, 'extensions.outlet_name', data.entries[uid].outletName);
3528+ !noSave && await saveWorldInfo(name, data);
3529+ });
3530+ outletNameInput.val(entry.outletName ?? '').trigger('input', { noSave: true });
3531+ setTimeout(() => createEntryInputAutocomplete(outletNameInput, getOutletNameCallback(data), { allowMultiple: true }), 1);
3532+
34283533 // Scan depth
34293534 const scanDepthInput = editTemplate.find('input[name="scanDepth"]');
34303535 scanDepthInput.data('uid', entry.uid);
@@ -3597,63 +3702,105 @@ export async function getWorldEntry(name, data, entry) {
35973702 return headerTemplate;
35983703}
35993704
3705+
36003706/**
3601- * Get the inclusion groups for the autocomplete.
3707+ * Builds a jQuery UI autocomplete callback: (control, request, response) => void
36023708 * @param {anyobject} data[opt={}] WI- dataOptional arguments
3603- * @returns {(input: any, output: any) => any} Callback function for the autocomplete
3709+ * @param {{entries: Record<string, any>}} [opt.data] - Your WI data
3710+ * @param {(entry:any)=>string|string[]|null|undefined} [opt.collectValues] - Extract values from one entry
3711+ * @param {() => Iterable<string>} [opt.includeExtras] - Optional global extras to include
3712+ * @param {(ctx:{result:string[], control:JQuery, input:any, haystack:string[]})=>string[]} [opt.postFilter] - Optional final filter step (for special rules like your "group" de-dupe logic)
36043713 */
3605-function getInclusionGroupCallback(data) {
3714+function buildAutocompleteCallback({ data, collectValues, includeExtras = () => [], postFilter } = {}) {
36063715 return function (control, input, output) {
36073716 const uid = $(control).data('uid');
3608- const thisGroups = String($(control).val()).split(/,\s*/).filter(x => x).map(x => x.toLowerCase());
3717+
3609- const groups = new Set();
3718+ // Collect unique values from all *other* entries
3610- for (const entry of Object.values(data.entries)) {
3719+ const values = new Set();
3611- // Skip the groups of this entry, because auto-complete should only suggest the ones that are already available on other entries
3720+ for (const entry of Object.values(data.entries ?? {})) {
36123721 if (entry?.uid == uid) continue;
3613- if (entry.group) {
3722+ const raw = collectValues(entry);
3614- entry.group.split(/,\s*/).filter(x => x).forEach(x => groups.add(x));
3723+ if (raw == null) continue;
3724+ const arr = Array.isArray(raw) ? raw : [raw];
3725+ for (const v of arr) {
3726+ const s = String(v).trim();
3727+ if (s) values.add(s);
36153728 }
36163729 }
36173730
3618- const haystack = Array.from(groups);
3731+ // Add optional global extras
3619- haystack.sort((a, b) => a.localeCompare(b));
3732+ for (const v of includeExtras()) {
36203733 const needles = input.termString(v).toLowerCasetrim();
3621- const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;
3734+ if (s) values.add(s);
3622- const result = haystack.filter(x => x.toLowerCase().includes(needle) && (!thisGroups.includes(x) || hasExactMatch && thisGroups.filter(g => g == x).length == 1));
3735+ }
3736+
3737+ // Sort stable & locale-aware
3738+ const haystack = Array.from(values).sort((a, b) => a.localeCompare(b));
3739+
3740+ // Case-insensitive contains
3741+ const needle = String(input.term ?? '').toLowerCase();
3742+ let result = haystack.filter(x => x.toLowerCase().includes(needle));
3743+
3744+ // Optional final-pass semantics
3745+ if (postFilter) {
3746+ result = postFilter({ result, control: $(control), input, haystack });
3747+ }
36233748
36243749 output(result);
36253750 };
36263751}
36273752
3628-function getAutomationIdCallback(data) {
3753+/**
3629- return function (control, input, output) {
3754+ * Splits a string into an array of strings, separated by commas and trimmed
3630- const uid = $(control).data('uid');
3755+ * @param {string} s - The string to split
3631- const ids = new Set();
3756+ * @returns {string[]} An array of strings, separated by commas and trimmed
3632- for (const entry of Object.values(data.entries)) {
3757+ */
3633- // Skip automation id of this entry, because auto-complete should only suggest the ones that are already available on other entries
3758+const splitCsv = s => String(s ?? '').split(/,\s*/).filter(Boolean);
3634- if (entry.uid == uid) continue;
3635- if (entry.automationId) {
3636- ids.add(String(entry.automationId));
3637- }
3638- }
36393759
3640- if ('quickReplyApi' in globalThis) {
3760+/**
3641- for (const automationId of globalThis.quickReplyApi.listAutomationIds()) {
3761+ * Get the inclusion groups for the autocomplete.
3642- ids.add(String(automationId));
3762+ * @param {any} data WI data
3643- }
3763+ * @returns {(input: any, output: any) => any} Callback function for the autocomplete
3644- }
3764+ */
3765+function getInclusionGroupCallback(data) {
3766+ return buildAutocompleteCallback({
3767+ data,
3768+ collectValues: entry => entry.group ? splitCsv(entry.group) : [],
3769+ postFilter: ({ result, control, input, haystack }) => {
3770+ const thisGroups = splitCsv(String($(control).val()));
3771+ const needle = String(input.term ?? '').toLowerCase();
3772+ const hasExactMatch = haystack.some(x => x.toLowerCase() === needle);
3773+
3774+ // include suggestion if it contains the needle AND
3775+ // (not already present OR (exact match typed && appears only once))
3776+ return result.filter(x =>
3777+ !thisGroups.includes(x) ||
3778+ (hasExactMatch && thisGroups.filter(g => g === x).length === 1),
3779+ );
3780+ },
3781+ });
3782+}
36453783
3646- const haystack = Array.from(ids);
3784+function getAutomationIdCallback(data) {
3647- haystack.sort((a, b) => a.localeCompare(b));
3785+ return buildAutocompleteCallback({
3648- const needle = input.term.toLowerCase();
3786+ data,
3649- const result = haystack.filter(x => x.toLowerCase().includes(needle));
3787+ collectValues: entry => entry.automationId != null ? [String(entry.automationId)] : [],
3788+ includeExtras: () =>
3789+ ('quickReplyApi' in globalThis && globalThis.quickReplyApi?.listAutomationIds)
3790+ ? globalThis.quickReplyApi.listAutomationIds()
3791+ : [],
3792+ });
3793+}
36503794
3651- output(result);
3795+function getOutletNameCallback(data) {
3652- };
3796+ return buildAutocompleteCallback({
3797+ data,
3798+ collectValues: entry => entry.position === world_info_position.outlet && entry.outletName ? [entry.outletName] : [],
3799+ });
36533800}
36543801
36553802/**
36563803 * Create an autocomplete for thean inclusioninput groupelement.
36573804 * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to
36583805 * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks
36593806 * @param {object} [options={}] - Optional arguments
@@ -3770,6 +3917,7 @@ export const newWorldInfoEntryDefinition = {
37703917 probability: { default: 100, type: 'number' },
37713918 useProbability: { default: true, type: 'boolean' },
37723919 depth: { default: DEFAULT_DEPTH, type: 'number' },
3920+ outletName: { default: '', type: 'string' },
37733921 group: { default: '', type: 'string' },
37743922 groupOverride: { default: false, type: 'boolean' },
37753923 groupWeight: { default: DEFAULT_WEIGHT, type: 'number' },
@@ -3935,6 +4083,16 @@ export async function deleteWorldInfo(worldInfoName) {
39354083 }
39364084 }
39374085
4086+ if (power_user.persona_description_lorebook === worldInfoName) {
4087+ power_user.persona_description_lorebook = '';
4088+ if (power_user.personas[user_avatar]) {
4089+ const object = getOrCreatePersonaDescriptor();
4090+ object.lorebook = '';
4091+ }
4092+ $('#persona_lore_button').toggleClass('world_set', false);
4093+ saveSettingsDebounced();
4094+ }
4095+
39384096 return true;
39394097}
39404098
@@ -4281,7 +4439,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
42814439 timedEffects.checkTimedEffects();
42824440
42834441 if (sortedEntries.length === 0) {
42844442 return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], ANBeforeEntries: [], ANAfterEntries: [], outletEntries: {}, allActivatedEntries: new Set() };
42854443 }
42864444
42874445 /** @type {number[]} Represents the delay levels for entries that are delayed until recursion */
@@ -4682,6 +4840,8 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
46824840 const ANTopEntries = [];
46834841 const ANBottomEntries = [];
46844842 const WIDepthEntries = [];
4843+ /** @type {{[key: string]: string[]}} */
4844+ const WIOutletEntries = {};
46854845
46864846 // Appends from insertion order 999 to 1. Use unshift for this purpose
46874847 // TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays
@@ -4730,6 +4890,18 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
47304890 }
47314891 break;
47324892 }
4893+ case world_info_position.outlet: {
4894+ if (!entry.outletName) {
4895+ console.warn(`[WI] Entry ${entry.uid} has position 'outlet' but no outlet name. Skipping.`);
4896+ break;
4897+ }
4898+ if (Array.isArray(WIOutletEntries[entry.outletName])) {
4899+ WIOutletEntries[entry.outletName].push(content);
4900+ } else {
4901+ WIOutletEntries[entry.outletName] = [content];
4902+ }
4903+ break;
4904+ }
47334905 default:
47344906 break;
47354907 }
@@ -4751,7 +4923,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
47514923 console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));
47524924 console.debug(`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---`);
47534925
47544926 return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, ANBeforeEntries: ANTopEntries, ANAfterEntries: ANBottomEntries, outletEntries: WIOutletEntries, allActivatedEntries: new Set(allActivatedEntries.values()) };
47554927}
47564928
47574929/**
@@ -4971,6 +5143,7 @@ function convertAgnaiMemoryBook(inputObj) {
49715143 displayIndex: index,
49725144 probability: 100,
49735145 useProbability: true,
5146+ outletName: '',
49745147 group: '',
49755148 groupOverride: false,
49765149 groupWeight: DEFAULT_WEIGHT,
@@ -5015,6 +5188,7 @@ function convertRisuLorebook(inputObj) {
50155188 displayIndex: index,
50165189 probability: entry.activationPercent ?? 100,
50175190 useProbability: entry.activationPercent ?? true,
5191+ outletName: '',
50185192 group: '',
50195193 groupOverride: false,
50205194 groupWeight: DEFAULT_WEIGHT,
@@ -5064,6 +5238,7 @@ function convertNovelLorebook(inputObj) {
50645238 displayIndex: index,
50655239 probability: 100,
50665240 useProbability: true,
5241+ outletName: '',
50675242 group: '',
50685243 groupOverride: false,
50695244 groupWeight: DEFAULT_WEIGHT,
@@ -5114,6 +5289,7 @@ export function convertCharacterBook(characterBook) {
51145289 useProbability: entry.extensions?.useProbability ?? true,
51155290 depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
51165291 selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
5292+ outletName: entry.extensions?.outlet_name ?? '',
51175293 group: entry.extensions?.group ?? '',
51185294 groupOverride: entry.extensions?.group_override ?? false,
51195295 groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,
@@ -5558,6 +5734,90 @@ export async function moveWorldInfoEntry(sourceName, targetName, uid, { deleteOr
55585734 }
55595735}
55605736
5737+
5738+/**
5739+ * Updates the primary world info linked to a character.
5740+ * Can also unset it to null.
5741+ * @param {string} name - The name of the world info to link to the character.
5742+ */
5743+export async function charUpdatePrimaryWorld(name) {
5744+ const previousValue = $('#character_world').val();
5745+ $('#character_world').val(name);
5746+
5747+ console.debug('Character world selected:', name);
5748+
5749+ if (menu_type == 'create') {
5750+ create_save.world = name;
5751+ return;
5752+ }
5753+
5754+ if (previousValue && !name) {
5755+ try {
5756+ // Dirty hack to remove embedded lorebook from character JSON data.
5757+ const data = JSON.parse(String($('#character_json_data').val()));
5758+
5759+ if (data?.data?.character_book) {
5760+ data.data.character_book = undefined;
5761+ }
5762+
5763+ $('#character_json_data').val(JSON.stringify(data));
5764+ toastr.info(t`Embedded lorebook will be removed from this character.`);
5765+ } catch {
5766+ console.error('Failed to parse character JSON data.');
5767+ }
5768+ }
5769+
5770+ await createOrEditCharacter();
5771+
5772+ setWorldInfoButtonClass(undefined, !!name);
5773+}
5774+
5775+/**
5776+ * Adds one or more auxiliary world books to a character.
5777+ * @param {string} characterKey - The key of the character to add auxiliary world books to
5778+ * @param {string|string[]} nameOrNames - The name or names of the auxiliary world books to add
5779+ */
5780+export async function charUpdateAddAuxWorld(characterKey, nameOrNames) {
5781+ const fileName = getCharaFilename(null, { manualAvatarKey: characterKey });
5782+ const toAdd = Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames];
5783+ updateAuxBooks(fileName, curr => [...curr, ...toAdd]);
5784+}
5785+
5786+/**
5787+ * Replaces the entire list of auxiliary world books for a character.
5788+ * @param {string} fileName - The filename of the character to update
5789+ * @param {string[]} books - The new list of auxiliary world books to replace the existing list with
5790+ */
5791+export function charSetAuxWorlds(fileName, books) {
5792+ updateAuxBooks(fileName, _ => Array.isArray(books) ? books : []);
5793+}
5794+
5795+function updateAuxBooks(fileName, computeNext) {
5796+ if (!fileName) return;
5797+
5798+ if (menu_type === 'create') {
5799+ const current = create_save.extra_books ?? [];
5800+ create_save.extra_books = normalizeArray(computeNext(current));
5801+ return; // no debounced save in create flow
5802+ }
5803+
5804+ const charLore = world_info.charLore ?? [];
5805+ const idx = charLore.findIndex(e => e.name === fileName);
5806+ const current = idx !== -1 ? (charLore[idx].extraBooks ?? []) : [];
5807+ const next = normalizeArray(computeNext(current));
5808+
5809+ if (next.length === 0) {
5810+ if (idx !== -1) charLore.splice(idx, 1);
5811+ } else if (idx === -1) {
5812+ charLore.push({ name: fileName, extraBooks: next });
5813+ } else {
5814+ charLore[idx] = { ...charLore[idx], extraBooks: next };
5815+ }
5816+
5817+ Object.assign(world_info, { charLore });
5818+ saveSettingsDebounced();
5819+}
5820+
55615821export function initWorldInfo() {
55625822 $('#world_info').on('mousedown change', async function (e) {
55635823 // If there's no world names, don't do anything
public/style.css+27 -3
@@ -133,6 +133,7 @@
133133* {
134134 box-sizing: border-box;
135135 -webkit-font-smoothing: antialiased;
136+ -webkit-tap-highlight-color: transparent;
136137 -moz-osx-font-smoothing: grayscale;
137138 text-shadow: 0px 0px calc(var(--shadowWidth) * 1px) var(--SmartThemeShadowColor);
138139}
@@ -599,6 +600,14 @@ input[type='checkbox']:focus-visible {
599600 max-height: var(--doc-height);
600601}
601602
603+.mes_reasoning_details[data-state="thinking"] .mes_reasoning .text_segment,
604+.mes_text .text_segment {
605+ animation-name: fade-in;
606+ animation-timing-function: ease-in-out;
607+ /* Not using variables for duration as they are zeroed when reduced motion is enabled */
608+ animation-duration: 300ms;
609+}
610+
602611.mes .mes_timer,
603612.mes .mesIDDisplay,
604613.mes .tokenCounterDisplay {
@@ -2923,6 +2932,10 @@ select option:not(:checked) {
29232932 color: var(--white70a);
29242933}
29252934
2935+select option[disabled]:not(:checked) {
2936+ color: var(--white30a);
2937+}
2938+
29262939/*#######################################################################*/
29272940
29282941#rm_api_block {
@@ -2940,6 +2953,7 @@ select option:not(:checked) {
29402953 text-align: center;
29412954}
29422955
2956+.menu_button[disabled],
29432957.menu_button.disabled {
29442958 filter: brightness(75%) grayscale(1);
29452959 opacity: 0.5;
@@ -3001,6 +3015,11 @@ select option:not(:checked) {
30013015 display: block;
30023016}
30033017
3018+/* Fix font size difference in API Connections dropdowns - Issue #4599 */
3019+#custom_model_id {
3020+ font-size: calc(var(--mainFontSize) * 0.95);
3021+}
3022+
30043023.menu_button.api_button:hover {
30053024 background-color: var(--active);
30063025}
@@ -3286,6 +3305,10 @@ input[type=search]:focus::-webkit-search-cancel-button {
32863305 position: relative;
32873306}
32883307
3308+#rm_group_top_bar:not(:has(#rm_group_activation_strategy option[value="0"]:checked)) label:has(#rm_group_allow_self_responses) {
3309+ display: none;
3310+}
3311+
32893312.group_member .queue_position:not(:empty)::before {
32903313 content: "#";
32913314}
@@ -3675,8 +3698,8 @@ grammarly-extension {
36753698 min-width: calc(1.25em + 12px);
36763699}
36773700
36783701.menu_button:not(.disabled):not([disabled]):hover,
36793702.menu_button:not(.disabled):not([disabled]).active {
36803703 background-color: var(--white30a);
36813704}
36823705
@@ -5740,7 +5763,8 @@ body:not(.movingUI) .drawer-content.maximized {
57405763}
57415764
57425765.paginationjs-pages ul li a {
57435766 padding: 0.05em 0.5em25em;
5767+ font-family: 'Font Awesome 6 Free';
57445768 text-decoration: none;
57455769 color: var(--SmartThemeBodyColor);
57465770 border: 1px solid var(--SmartThemeBorderColor);
src/config-init.js+10 -0
@@ -124,6 +124,16 @@ const keyMigrationMap = [
124124 migrate: () => void 0,
125125 remove: true,
126126 },
127+ {
128+ oldKey: 'autheliaAuth',
129+ newKey: 'sso.autheliaAuth',
130+ migrate: (value) => value,
131+ },
132+ {
133+ oldKey: 'authentikAuth',
134+ newKey: 'sso.authentikAuth',
135+ migrate: (value) => value,
136+ },
127137];
128138
129139/**
src/endpoints/backends/chat-completions.js+73 -26
@@ -154,9 +154,9 @@ async function sendClaudeRequest(request, response) {
154154 const useTools = Array.isArray(request.body.tools) && request.body.tools.length > 0;
155155 const useSystemPrompt = Boolean(request.body.claude_use_sysprompt);
156156 const convertedPrompt = convertClaudeMessages(request.body.messages, request.body.assistant_prefill, useSystemPrompt, useTools, getPromptNames(request));
157157 const useThinking = /^claude-(3-7|opus-4|sonnet-4|haiku-4-5)/.test(request.body.model);
158158 const useWebSearch = /^claude-(3-5|3-7|opus-4|sonnet-4|haiku-4-5)/.test(request.body.model) && Boolean(request.body.enable_web_search);
159159 const isOpus41isLimitedSampling = /^claude-(opus-4-1|sonnet-4-5|haiku-4-5)/.test(request.body.model);
160160 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';
161161 let fixThinkingPrefill = false;
162162 // Add custom stop sequences
@@ -226,7 +226,7 @@ async function sendClaudeRequest(request, response) {
226226 betaHeaders.push('extended-cache-ttl-2025-04-11');
227227 }
228228
229229 if (isOpus41isLimitedSampling) {
230230 if (requestBody.top_p < 1) {
231231 delete requestBody.temperature;
232232 } else {
@@ -377,24 +377,16 @@ async function sendMakerSuiteRequest(request, response) {
377377 'gemini-2.0-flash-exp-image-generation',
378378 'gemini-2.0-flash-preview-image-generation',
379379 'gemini-2.5-flash-image-preview',
380+ 'gemini-2.5-flash-image',
380381 ];
381382
382- // These models do not support setting the threshold to OFF at all.
383+ const isThinkingConfigModel = m => /^gemini-2.5-(flash|pro)/.test(m) && !/-image(-preview)?$/.test(m);
383- const blockNoneModels = [
384- 'gemini-1.5-pro-001',
385- 'gemini-1.5-flash-001',
386- 'gemini-1.5-flash-8b-exp-0827',
387- 'gemini-1.5-flash-8b-exp-0924',
388- ];
389-
390- const isThinkingConfigModel = m => /^gemini-2.5-(flash|pro)/.test(m) && !/-image-preview$/.test(m);
391384
392385 const noSearchModels = [
393386 'gemini-2.0-flash-lite',
394387 'gemini-2.0-flash-lite-001',
395388 'gemini-2.0-flash-lite-preview-02-05',
396389 'gemini-robotics-er-1.5-flash-8b-exp-0924preview',
397- 'gemini-1.5-flash-8b-exp-0827',
398390 ];
399391 // #endregion
400392
@@ -413,15 +405,8 @@ async function sendMakerSuiteRequest(request, response) {
413405 const prompt = convertGooglePrompt(request.body.messages, model, useSystemPrompt, getPromptNames(request));
414406 let safetySettings = GEMINI_SAFETY;
415407
416- if (blockNoneModels.includes(model)) {
417- safetySettings = GEMINI_SAFETY.map(setting => ({ ...setting, threshold: 'BLOCK_NONE' }));
418- }
419-
420408 if (enableWebSearch && !enableImageModality && !isGemma && !isLearnLM && !noSearchModels.includes(model)) {
421- const searchTool = model.includes('1.5')
409+ tools.push({ google_search: {} });
422- ? ({ google_search_retrieval: {} })
423- : ({ google_search: {} });
424- tools.push(searchTool);
425410 }
426411
427412 if (Array.isArray(request.body.tools) && request.body.tools.length > 0 && !enableImageModality && !isGemma) {
@@ -1012,7 +997,7 @@ async function sendXaiRequest(request, response) {
1012997 bodyParams['stop'] = request.body.stop;
1013998 }
1014999
1015- if (request.body.reasoning_effort && ['grok-3-mini-beta', 'grok-3-mini-fast-beta'].includes(request.body.model)) {
1000+ if (request.body.reasoning_effort) {
10161001 bodyParams['reasoning_effort'] = request.body.reasoning_effort === 'high' ? 'high' : 'low';
10171002 }
10181003
@@ -1255,6 +1240,7 @@ async function sendElectronHubRequest(request, response) {
12551240 'frequency_penalty': request.body.frequency_penalty,
12561241 'top_p': request.body.top_p,
12571242 'top_k': request.body.top_k,
1243+ 'logit_bias': request.body.logit_bias,
12581244 'seed': request.body.seed,
12591245 ...bodyParams,
12601246 };
@@ -1848,9 +1834,9 @@ router.post('/generate', function (request, response) {
18481834 }
18491835
18501836 const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number');
18511837 const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4|haiku-4)/.test(request.body.model);
18521838 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';
18531839 if (Array.isArray(request.body.messages) && Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && isClaude3or4) {
18541840 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth, cacheTTL);
18551841 }
18561842
@@ -2209,4 +2195,65 @@ multimodalModels.post('/electronhub', async (_req, res) => {
22092195 }
22102196});
22112197
2198+multimodalModels.post('/mistral', async (req, res) => {
2199+ try {
2200+ const key = readSecret(req.user.directories, SECRET_KEYS.MISTRALAI);
2201+
2202+ if (!key) {
2203+ return res.json([]);
2204+ }
2205+
2206+ const response = await fetch('https://api.mistral.ai/v1/models', {
2207+ headers: {
2208+ 'Authorization': `Bearer ${key}`,
2209+ },
2210+ });
2211+
2212+ if (!response.ok) {
2213+ return res.json([]);
2214+ }
2215+
2216+ /** @type {any} */
2217+ const data = await response.json();
2218+ const multimodalModels = data.data.filter(m => m.capabilities?.vision).map(m => m.id);
2219+ return res.json(multimodalModels);
2220+ } catch (error) {
2221+ console.error(error);
2222+ return res.sendStatus(500);
2223+ }
2224+});
2225+
2226+multimodalModels.post('/xai', async (req, res) => {
2227+ try {
2228+ const key = readSecret(req.user.directories, SECRET_KEYS.XAI);
2229+
2230+ if (!key) {
2231+ return res.json([]);
2232+ }
2233+
2234+ // xAI's /models endpoint doesn't return modality info, so we must use /language-models instead
2235+ const response = await fetch('https://api.x.ai/v1/language-models', {
2236+ headers: {
2237+ 'Authorization': `Bearer ${key}`,
2238+ },
2239+ });
2240+
2241+ if (!response.ok) {
2242+ return res.json([]);
2243+ }
2244+
2245+ /** @type {any} */
2246+ const data = await response.json();
2247+ const multimodalModels = data.models.filter(m => m.input_modalities?.includes('image')).map(m => m.id);
2248+ if (!multimodalModels.includes('grok-4-0709')) {
2249+ // The endpoint says it doesn't support images, but it does
2250+ multimodalModels.push('grok-4-0709');
2251+ }
2252+ return res.json(multimodalModels);
2253+ } catch (error) {
2254+ console.error(error);
2255+ return res.sendStatus(500);
2256+ }
2257+});
2258+
22122259router.use('/multimodal-models', multimodalModels);
src/endpoints/characters.js+2 -0
@@ -108,6 +108,7 @@ class DiskCache {
108108 dir: this.cachePath,
109109 ttl: false,
110110 forgiveParseErrors: true,
111+ expiredInterval: 0,
111112 // @ts-ignore
112113 maxFileDescriptors: 100,
113114 });
@@ -688,6 +689,7 @@ function convertWorldInfoToCharacterBook(name, entries) {
688689 useProbability: entry.useProbability ?? false,
689690 depth: entry.depth ?? 4,
690691 selectiveLogic: entry.selectiveLogic ?? 0,
692+ outlet_name: entry.outletName ?? '',
691693 group: entry.group ?? '',
692694 group_override: entry.groupOverride ?? false,
693695 group_weight: entry.groupWeight ?? null,
src/endpoints/chats.js+5 -2
@@ -628,6 +628,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
628628 const avatarUrl = (request.body.avatar_url).replace('.png', '');
629629 const characterName = request.body.character_name;
630630 const userName = request.body.user_name || 'User';
631+ const fileNames = [];
631632
632633 if (!request.file) {
633634 return response.sendStatus(400);
@@ -662,6 +663,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
662663 const handleChat = (chat) => {
663664 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
664665 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
666+ fileNames.push(fileName);
665667 writeFileAtomicSync(filePath, chat, 'utf8');
666668 };
667669
@@ -673,7 +675,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
673675 handleChat(chat);
674676 }
675677
676678 return response.send({ res: true, fileNames });
677679 }
678680
679681 if (format === 'jsonl') {
@@ -700,13 +702,14 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
700702
701703 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
702704 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
705+ fileNames.push(fileName);
703706 if (flattenedChat !== data) {
704707 writeFileAtomicSync(filePath, flattenedChat, 'utf8');
705708 } else {
706709 fs.copyFileSync(pathToUpload, filePath);
707710 }
708711 fs.unlinkSync(pathToUpload);
709712 response.send({ res: true, fileNames });
710713 }
711714 } catch (error) {
712715 console.error(error);
src/endpoints/openai.js+98 -0
@@ -325,6 +325,104 @@ router.post('/generate-voice', async (request, response) => {
325325 }
326326});
327327
328+// ElectronHub TTS proxy
329+router.post('/electronhub/generate-voice', async (request, response) => {
330+ try {
331+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
332+
333+ if (!key) {
334+ console.warn('No ElectronHub key found');
335+ return response.sendStatus(400);
336+ }
337+
338+ const requestBody = {
339+ input: request.body.input,
340+ voice: request.body.voice,
341+ speed: request.body.speed ?? 1,
342+ temperature: request.body.temperature ?? undefined,
343+ model: request.body.model || 'tts-1',
344+ response_format: 'mp3',
345+ };
346+
347+ // Optional provider-specific params
348+ if (request.body.instructions) requestBody.instructions = request.body.instructions;
349+ if (request.body.speaker_transcript) requestBody.speaker_transcript = request.body.speaker_transcript;
350+ if (Number.isFinite(request.body.cfg_scale)) requestBody.cfg_scale = Number(request.body.cfg_scale);
351+ if (Number.isFinite(request.body.cfg_filter_top_k)) requestBody.cfg_filter_top_k = Number(request.body.cfg_filter_top_k);
352+ if (Number.isFinite(request.body.speech_rate)) requestBody.speech_rate = Number(request.body.speech_rate);
353+ if (Number.isFinite(request.body.pitch_adjustment)) requestBody.pitch_adjustment = Number(request.body.pitch_adjustment);
354+ if (request.body.emotional_style) requestBody.emotional_style = request.body.emotional_style;
355+
356+ // Handle dynamic parameters sent from the frontend
357+ const knownParams = new Set(Object.keys(requestBody));
358+ for (const key in request.body) {
359+ if (!knownParams.has(key) && request.body[key] !== undefined) {
360+ requestBody[key] = request.body[key];
361+ }
362+ }
363+
364+ // Clean undefineds
365+ Object.keys(requestBody).forEach(k => requestBody[k] === undefined && delete requestBody[k]);
366+
367+ console.debug('ElectronHub TTS request', requestBody);
368+
369+ const result = await fetch('https://api.electronhub.ai/v1/audio/speech', {
370+ method: 'POST',
371+ headers: {
372+ 'Content-Type': 'application/json',
373+ Authorization: `Bearer ${key}`,
374+ },
375+ body: JSON.stringify(requestBody),
376+ });
377+
378+ if (!result.ok) {
379+ const text = await result.text();
380+ console.warn('ElectronHub TTS request failed', result.statusText, text);
381+ return response.status(500).send(text);
382+ }
383+
384+ const contentType = result.headers.get('content-type') || 'audio/mpeg';
385+ const buffer = await result.arrayBuffer();
386+ response.setHeader('Content-Type', contentType);
387+ return response.send(Buffer.from(buffer));
388+ } catch (error) {
389+ console.error('ElectronHub TTS generation failed', error);
390+ response.status(500).send('Internal server error');
391+ }
392+});
393+
394+// ElectronHub model list
395+router.post('/electronhub/models', async (request, response) => {
396+ try {
397+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
398+
399+ if (!key) {
400+ console.warn('No ElectronHub key found');
401+ return response.sendStatus(400);
402+ }
403+
404+ const result = await fetch('https://api.electronhub.ai/v1/models', {
405+ method: 'GET',
406+ headers: {
407+ Authorization: `Bearer ${key}`,
408+ },
409+ });
410+
411+ if (!result.ok) {
412+ const text = await result.text();
413+ console.warn('ElectronHub models request failed', result.statusText, text);
414+ return response.status(500).send(text);
415+ }
416+
417+ const data = await result.json();
418+ const models = data && Array.isArray(data['data']) ? data['data'] : [];
419+ return response.json(models);
420+ } catch (error) {
421+ console.error('ElectronHub models fetch failed', error);
422+ response.status(500).send('Internal server error');
423+ }
424+});
425+
328426router.post('/generate-image', async (request, response) => {
329427 try {
330428 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
src/endpoints/secrets.js+3 -2
@@ -561,12 +561,13 @@ router.post('/find', (request, response) => {
561561 }
562562
563563 const manager = new SecretManager(request.user.directories);
564564 const secretValuestate = manager.readSecretgetSecretState(key, id);
565565
566566 if (!secretValuestate[key]) {
567567 return response.sendStatus(404);
568568 }
569569
570+ const secretValue = manager.readSecret(key, id);
570571 return response.send({ value: secretValue });
571572 } catch (error) {
572573 console.error('Error finding secret:', error);
src/endpoints/vectors.js+1 -1
@@ -187,7 +187,7 @@ function getSourceSettings(source, request) {
187187 case 'palm':
188188 case 'vertexai':
189189 return {
190190 model: String(request.body.model || 'text-embedding-004005'),
191191 request: request, // Pass the request object to get API key and URL
192192 };
193193 case 'mistral':
src/middleware/webpack-serve.js+2 -1
@@ -14,8 +14,9 @@ export default function getWebpackServeMiddleware() {
1414 const publicLibConfig = getPublicLibConfig();
1515 const outputPath = publicLibConfig.output?.path;
1616 const outputFile = publicLibConfig.output?.filename;
17+ const parsedPath = path.parse(req.path);
1718
1819 if (req.method === 'GET' && path.parse(reqparsedPath.path)dir === '/' && parsedPath.base === outputFile) {
1920 return res.sendFile(outputFile, { root: outputPath });
2021 }
2122
src/users.js+30 -3
@@ -24,7 +24,8 @@ import { serverDirectory } from './server-directory.js';
2424export const KEY_PREFIX = 'user:';
2525const AVATAR_PREFIX = 'avatar:';
2626const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
2727const AUTHELIA_AUTH = getConfigValue('sso.autheliaAuth', false, 'boolean');
28+const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean');
2829const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');
2930const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
3031
@@ -511,6 +512,7 @@ export async function initUserStorage(dataRoot) {
511512 await storage.init({
512513 dir: path.join(dataRoot, '_storage'),
513514 ttl: false, // Never expire
515+ expiredInterval: 0,
514516 });
515517
516518 const keys = await getAllUserHandles();
@@ -715,6 +717,10 @@ export async function tryAutoLogin(request, basicAuthMode) {
715717 return true;
716718 }
717719
720+ if (AUTHENTIK_AUTH && await authentikUserLogin(request)) {
721+ return true;
722+ }
723+
718724 if (basicAuthMode && PER_USER_BASIC_AUTH && await basicUserLogin(request)) {
719725 return true;
720726 }
@@ -745,20 +751,41 @@ async function singleUserLogin(request) {
745751}
746752
747753/**
748754 * TriesAttempts auto-login withusing authliaan trustedAuthelia headersheader.
749755 * https://www.authelia.com/integration/trusted-header-sso/introduction/
750756 * @param {import('express').Request} request Request object
751757 * @returns {Promise<boolean>} Whether auto-login was performed
752758 */
753759async function autheliaUserLogin(request) {
760+ return headerUserLogin(request, 'Remote-User');
761+}
762+
763+/**
764+ * Attempts auto-login using an Authentik header.
765+ * https://docs.goauthentik.io/add-secure-apps/providers/proxy/forward_auth/
766+ * @param {import('express').Request} request Request object
767+ * @returns {Promise<boolean>} Whether auto-login was performed
768+ */
769+async function authentikUserLogin(request) {
770+ return headerUserLogin(request, 'X-Authentik-Username');
771+}
772+
773+/**
774+ * Tries auto-login with a given header.
775+ * @param {import('express').Request} request Request object
776+ * @param {string} [header='Remote-User'] The header to use for the trusted user
777+ * @returns {Promise<boolean>} Whether auto-login was performed
778+ */
779+async function headerUserLogin(request, header = 'Remote-User') {
754780 if (!request.session) {
755781 return false;
756782 }
757783
758784 const remoteUser = request.get('Remote-User'header);
759785 if (!remoteUser) {
760786 return false;
761787 }
788+ console.debug(`Attempting auto-login for user from header ${header}: ${remoteUser}`);
762789
763790 const userHandles = await getAllUserHandles();
764791 for (const userHandle of userHandles) {