Merge pull request #4656 from SillyTavern/staging Staging

74c158bd2e98b8b4dc54d2bb0d088c5a5e918826

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

Signed
103 files changed, +4224 -1388Showing whitespace changes
.github/pr-auto-comments.yml+10 -0
@@ -41,6 +41,16 @@ labels:
41 🔬 This PR needs testing!41 🔬 This PR needs testing!
42 Any contributor can test and leave reviews, so feel free to help us out!42 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
44 - name: 🟥 ⬤⬤⬤⬤⬤54 - name: 🟥 ⬤⬤⬤⬤⬤
45 labeled:55 labeled:
46 pr:56 pr:
.github/pull_request_template.md+1 -1
@@ -2,4 +2,4 @@
22
3## Checklist:3## Checklist:
44
5- [ ] I have read the [Contributing guidelines](https://github.com/SillyTavern/SillyTavern/blob/release/CONTRIBUTING.md).5- [ ] I have read the [Contribution guidelines](https://github.com/SillyTavern/SillyTavern/blob/release/CONTRIBUTING.md).
.github/workflows/issues-auto-manager.yml+54 -9
@@ -15,8 +15,19 @@ jobs:
15 label-on-content:15 label-on-content:
16 name: 🏷️ Label Issues by Content16 name: 🏷️ Label Issues by Content
17 runs-on: ubuntu-latest17 runs-on: ubuntu-latest
18 if: always()
1819
19 steps:20 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
20 - name: Checkout Repository31 - name: Checkout Repository
21 # Checkout32 # Checkout
22 # https://github.com/marketplace/actions/checkout33 # https://github.com/marketplace/actions/checkout
@@ -32,13 +43,25 @@ jobs:
32 with:43 with:
33 configuration-path: .github/issues-auto-labels.yml44 configuration-path: .github/issues-auto-labels.yml
34 enable-versioned-regex: 045 enable-versioned-regex: 0
35 repo-token: ${{ secrets.GITHUB_TOKEN }}46 repo-token: ${{ steps.app.outputs.token }}
3647
37 label-on-labels:48 label-on-labels:
38 name: 🏷️ Label Issues by Labels49 name: 🏷️ Label Issues by Labels
50 needs: [label-on-content]
39 runs-on: ubuntu-latest51 runs-on: ubuntu-latest
52 if: always()
4053
41 steps:54 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
42 - name: ✅ Add "👍 Approved" for relevant labels65 - name: ✅ Add "👍 Approved" for relevant labels
43 if: contains(fromJSON('["👩‍💻 Good First Issue", "🙏 Help Wanted", "🪲 Confirmed", "⚠️ High Priority", "❕ Medium Priority", "💤 Low Priority"]'), github.event.label.name)66 if: contains(fromJSON('["👩‍💻 Good First Issue", "🙏 Help Wanted", "🪲 Confirmed", "⚠️ High Priority", "❕ Medium Priority", "💤 Low Priority"]'), github.event.label.name)
44 # 🤖 Issues Helper67 # 🤖 Issues Helper
@@ -46,7 +69,7 @@ jobs:
46 uses: actions-cool/issues-helper@v3.6.069 uses: actions-cool/issues-helper@v3.6.0
47 with:70 with:
48 actions: 'add-labels'71 actions: 'add-labels'
49 token: ${{ secrets.GITHUB_TOKEN }}72 token: ${{ steps.app.outputs.token }}
50 labels: '👍 Approved'73 labels: '👍 Approved'
5174
52 - name: ❌ Remove progress labels when issue is marked done or stale75 - name: ❌ Remove progress labels when issue is marked done or stale
@@ -56,7 +79,7 @@ jobs:
56 uses: actions-cool/issues-helper@v3.6.079 uses: actions-cool/issues-helper@v3.6.0
57 with:80 with:
58 actions: 'remove-labels'81 actions: 'remove-labels'
59 token: ${{ secrets.GITHUB_TOKEN }}82 token: ${{ steps.app.outputs.token }}
60 labels: '🧑‍💻 In Progress,🤔 Unsure,🤔 Under Consideration'83 labels: '🧑‍💻 In Progress,🤔 Unsure,🤔 Under Consideration'
6184
62 - name: ❌ Remove temporary labels when confirmed labels are added85 - name: ❌ Remove temporary labels when confirmed labels are added
@@ -66,7 +89,7 @@ jobs:
66 uses: actions-cool/issues-helper@v3.6.089 uses: actions-cool/issues-helper@v3.6.0
67 with:90 with:
68 actions: 'remove-labels'91 actions: 'remove-labels'
69 token: ${{ secrets.GITHUB_TOKEN }}92 token: ${{ steps.app.outputs.token }}
70 labels: '🤔 Unsure,🤔 Under Consideration'93 labels: '🤔 Unsure,🤔 Under Consideration'
7194
72 - name: ❌ Remove no bug labels when "🪲 Confirmed" is added95 - name: ❌ Remove no bug labels when "🪲 Confirmed" is added
@@ -76,32 +99,54 @@ jobs:
76 uses: actions-cool/issues-helper@v3.6.099 uses: actions-cool/issues-helper@v3.6.0
77 with:100 with:
78 actions: 'remove-labels'101 actions: 'remove-labels'
79 token: ${{ secrets.GITHUB_TOKEN }}102 token: ${{ steps.app.outputs.token }}
80 labels: '✖️ Not Reproducible,✖️ Not A Bug'103 labels: '✖️ Not Reproducible,✖️ Not A Bug'
81104
82 remove-stale-label:105 remove-stale-label:
83 name: 🗑️ Remove Stale Label on Comment106 name: 🗑️ Remove Stale Label on Comment
107 needs: [label-on-content, label-on-labels]
84 runs-on: ubuntu-latest108 runs-on: ubuntu-latest
85 # Only run this on new comments, to automatically remove the stale label109 # Only run this on new comments, to automatically remove the stale label
86 if: github.event_name == 'issue_comment' && github.actor != 'github-actions[bot]'110 if: always() && (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
87111
88 steps:112 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
89 - name: Remove Stale Label123 - name: Remove Stale Label
90 # 🤖 Issues Helper124 # 🤖 Issues Helper
91 # https://github.com/marketplace/actions/issues-helper125 # https://github.com/marketplace/actions/issues-helper
92 uses: actions-cool/issues-helper@v3.6.0126 uses: actions-cool/issues-helper@v3.6.0
93 with:127 with:
94 actions: 'remove-labels'128 actions: 'remove-labels'
95 token: ${{ secrets.GITHUB_TOKEN }}129 token: ${{ steps.app.outputs.token }}
96 issue-number: ${{ github.event.issue.number }}130 issue-number: ${{ github.event.issue.number }}
97 labels: '⚰️ Stale,🕸️ Inactive,🚏 Awaiting User Response,🛑 No Response'131 labels: '⚰️ Stale,🕸️ Inactive,🚏 Awaiting User Response,🛑 No Response'
98132
99 write-auto-comments:133 write-auto-comments:
100 name: 💬 Post Issue Comments Based on Labels134 name: 💬 Post Issue Comments Based on Labels
101 needs: [label-on-content, label-on-labels]135 needs: [label-on-content, label-on-labels, remove-stale-label]
102 runs-on: ubuntu-latest136 runs-on: ubuntu-latest
137 if: always()
103138
104 steps:139 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
105 - name: Checkout Repository150 - name: Checkout Repository
106 # Checkout151 # Checkout
107 # https://github.com/marketplace/actions/checkout152 # https://github.com/marketplace/actions/checkout
@@ -113,4 +158,4 @@ jobs:
113 uses: peaceiris/actions-label-commenter@v1.10.0158 uses: peaceiris/actions-label-commenter@v1.10.0
114 with:159 with:
115 config_file: .github/issues-auto-comments.yml160 config_file: .github/issues-auto-comments.yml
116 github_token: ${{ secrets.GITHUB_TOKEN }}161 github_token: ${{ steps.app.outputs.token }}
.github/workflows/issues-updates-on-merge.yml+12 -1
@@ -15,8 +15,19 @@ jobs:
15 update-linked-issues:15 update-linked-issues:
16 name: 🔗 Mark Linked Issues Done on Push16 name: 🔗 Mark Linked Issues Done on Push
17 runs-on: ubuntu-latest17 runs-on: ubuntu-latest
18 if: always()
1819
19 steps:20 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
20 - name: Checkout Repository31 - name: Checkout Repository
21 # Checkout32 # Checkout
22 # https://github.com/marketplace/actions/checkout33 # https://github.com/marketplace/actions/checkout
@@ -31,7 +42,7 @@ jobs:
31 - name: Label Linked Issues42 - name: Label Linked Issues
32 id: label_linked_issues43 id: label_linked_issues
33 env:44 env:
34 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}45 GH_TOKEN: ${{ steps.app.outputs.token }}
35 run: |46 run: |
36 for ISSUE in $(echo $issues | jq -r '.[]'); do47 for ISSUE in $(echo $issues | jq -r '.[]'); do
37 if [ "${{ github.ref }}" == "refs/heads/staging" ]; then48 if [ "${{ github.ref }}" == "refs/heads/staging" ]; then
.github/workflows/job-close-stale.yml+38 -5
@@ -15,14 +15,25 @@ jobs:
15 mark-inactivity:15 mark-inactivity:
16 name: ⏳ Mark Issues/PRs without Activity16 name: ⏳ Mark Issues/PRs without Activity
17 runs-on: ubuntu-latest17 runs-on: ubuntu-latest
18 if: always()
1819
19 steps:20 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
20 - name: Mark Issues/PRs without Activity31 - name: Mark Issues/PRs without Activity
21 # Close Stale Issues and PRs32 # Close Stale Issues and PRs
22 # https://github.com/marketplace/actions/close-stale-issues33 # https://github.com/marketplace/actions/close-stale-issues
23 uses: actions/stale@v9.1.034 uses: actions/stale@v9.1.0
24 with:35 with:
25 repo-token: ${{ secrets.GITHUB_TOKEN }}36 repo-token: ${{ steps.app.outputs.token }}
26 days-before-stale: 18337 days-before-stale: 183
27 days-before-close: 738 days-before-close: 7
28 operations-per-run: 3039 operations-per-run: 30
@@ -47,16 +58,27 @@ jobs:
4758
48 await-user-response:59 await-user-response:
49 name: ⚠️ Mark Issues/PRs Awaiting User Response60 name: ⚠️ Mark Issues/PRs Awaiting User Response
61 needs: [mark-inactivity]
50 runs-on: ubuntu-latest62 runs-on: ubuntu-latest
51 needs: mark-inactivity63 if: always()
5264
53 steps:65 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
54 - name: Mark Issues/PRs Awaiting User Response76 - name: Mark Issues/PRs Awaiting User Response
55 # Close Stale Issues and PRs77 # Close Stale Issues and PRs
56 # https://github.com/marketplace/actions/close-stale-issues78 # https://github.com/marketplace/actions/close-stale-issues
57 uses: actions/stale@v9.1.079 uses: actions/stale@v9.1.0
58 with:80 with:
59 repo-token: ${{ secrets.GITHUB_TOKEN }}81 repo-token: ${{ steps.app.outputs.token }}
60 days-before-stale: 782 days-before-stale: 7
61 days-before-close: 783 days-before-close: 7
62 operations-per-run: 3084 operations-per-run: 30
@@ -74,16 +96,27 @@ jobs:
7496
75 alternative-exists:97 alternative-exists:
76 name: 🔄 Mark Issues with Alternative Exists98 name: 🔄 Mark Issues with Alternative Exists
99 needs: [mark-inactivity, await-user-response]
77 runs-on: ubuntu-latest100 runs-on: ubuntu-latest
78 needs: await-user-response101 if: always()
79102
80 steps:103 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
81 - name: Mark Issues with Alternative Exists114 - name: Mark Issues with Alternative Exists
82 # Close Stale Issues and PRs115 # Close Stale Issues and PRs
83 # https://github.com/marketplace/actions/close-stale-issues116 # https://github.com/marketplace/actions/close-stale-issues
84 uses: actions/stale@v9.1.0117 uses: actions/stale@v9.1.0
85 with:118 with:
86 repo-token: ${{ secrets.GITHUB_TOKEN }}119 repo-token: ${{ steps.app.outputs.token }}
87 days-before-stale: 7120 days-before-stale: 7
88 days-before-close: 7121 days-before-close: 7
89 operations-per-run: 30122 operations-per-run: 30
.github/workflows/on-close-handler.yml+12 -1
@@ -15,14 +15,25 @@ jobs:
15 remove-labels:15 remove-labels:
16 name: 🗑️ Remove Pending Labels on Close16 name: 🗑️ Remove Pending Labels on Close
17 runs-on: ubuntu-latest17 runs-on: ubuntu-latest
18 if: always()
1819
19 steps:20 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
20 - name: Remove Pending Labels on Close31 - name: Remove Pending Labels on Close
21 # 🤖 Issues Helper32 # 🤖 Issues Helper
22 # https://github.com/marketplace/actions/issues-helper33 # https://github.com/marketplace/actions/issues-helper
23 uses: actions-cool/issues-helper@v3.6.034 uses: actions-cool/issues-helper@v3.6.0
24 with:35 with:
25 actions: remove-labels36 actions: remove-labels
26 token: ${{ secrets.GITHUB_TOKEN }}37 token: ${{ steps.app.outputs.token }}
27 issue-number: ${{ github.event.issue.number || github.event.pull_request.number }}38 issue-number: ${{ github.event.issue.number || github.event.pull_request.number }}
28 labels: '🚏 Awaiting User Response,🧑‍💻 In Progress,📌 Keep Open,🚫 Merge Conflicts,🔬 Needs Testing,🔨 Needs Work,⚰️ Stale,⛔ Waiting For External/Upstream'39 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:
15 label-maintainer:15 label-maintainer:
16 name: 🏷️ Label if Author is a Repo Maintainer16 name: 🏷️ Label if Author is a Repo Maintainer
17 runs-on: ubuntu-latest17 runs-on: ubuntu-latest
18 if: contains(fromJson('["Cohee1207", "RossAscends", "Wolfsblvt"]'), github.actor)18 if: always() && contains(fromJson('["Cohee1207", "RossAscends", "Wolfsblvt"]'), github.actor)
1919
20 steps:20 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
21 - name: Label if Author is a Repo Maintainer31 - name: Label if Author is a Repo Maintainer
22 # 🤖 Issues Helper32 # 🤖 Issues Helper
23 # https://github.com/marketplace/actions/issues-helper33 # https://github.com/marketplace/actions/issues-helper
24 uses: actions-cool/issues-helper@v3.6.034 uses: actions-cool/issues-helper@v3.6.0
25 with:35 with:
26 actions: 'add-labels'36 actions: 'add-labels'
27 token: ${{ secrets.GITHUB_TOKEN }}37 token: ${{ steps.app.outputs.token }}
28 issue-number: ${{ github.event.issue.number || github.event.pull_request.number }}38 issue-number: ${{ github.event.issue.number || github.event.pull_request.number }}
29 labels: '👷 Maintainer'39 labels: '👷 Maintainer'
.github/workflows/pr-auto-manager.yml+88 -16
@@ -16,7 +16,7 @@ jobs:
16 name: ✅ Check ESLint on PR16 name: ✅ Check ESLint on PR
17 runs-on: ubuntu-latest17 runs-on: ubuntu-latest
18 # Only needs to run when code is changed18 # Only needs to run when code is changed
19 if: github.event.action == 'opened' || github.event.action == 'synchronize'19 if: always() && (github.event.action == 'opened' || github.event.action == 'synchronize')
2020
21 # Override permissions, linter likely needs write access to issues21 # Override permissions, linter likely needs write access to issues
22 permissions:22 permissions:
@@ -48,7 +48,7 @@ jobs:
48 # https://github.com/marketplace/actions/action-eslint48 # https://github.com/marketplace/actions/action-eslint
49 uses: sibiraj-s/action-eslint@v3.0.149 uses: sibiraj-s/action-eslint@v3.0.1
50 with:50 with:
51 token: ${{ secrets.GITHUB_TOKEN }}51 token: ${{ secrets.GITHUB_TOKEN }} # ESLint can run with the original permissions
52 eslint-args: '--ignore-path=.gitignore --quiet'52 eslint-args: '--ignore-path=.gitignore --quiet'
53 extensions: 'js'53 extensions: 'js'
54 annotations: true54 annotations: true
@@ -71,12 +71,22 @@ jobs:
71 pull-requests: write71 pull-requests: write
7272
73 steps:73 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
74 - name: Label PR Size84 - name: Label PR Size
75 # Pull Request Size Labeler85 # Pull Request Size Labeler
76 # https://github.com/marketplace/actions/pull-request-size-labeler86 # https://github.com/marketplace/actions/pull-request-size-labeler
77 uses: codelytv/pr-size-labeler@v1.10.287 uses: codelytv/pr-size-labeler@v1.10.2
78 with:88 with:
79 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}89 GITHUB_TOKEN: ${{ steps.app.outputs.token }}
80 xs_label: '🟩 ⬤○○○○'90 xs_label: '🟩 ⬤○○○○'
81 xs_max_size: '20'91 xs_max_size: '20'
82 s_label: '🟩 ⬤⬤○○○'92 s_label: '🟩 ⬤⬤○○○'
@@ -95,9 +105,19 @@ jobs:
95 name: 🏷️ Label PR by Branches105 name: 🏷️ Label PR by Branches
96 runs-on: ubuntu-latest106 runs-on: ubuntu-latest
97 # Only label once when PR is created or when base branch is changed, to allow manual label removal107 # Only label once when PR is created or when base branch is changed, to allow manual label removal
98 if: github.event.action == 'opened' || (github.event.action == 'synchronize' && github.event.changes.base)108 if: always() && (github.event.action == 'opened' || (github.event.action == 'synchronize' && github.event.changes.base))
99109
100 steps:110 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
101 - name: Checkout Repository121 - name: Checkout Repository
102 # Checkout122 # Checkout
103 # https://github.com/marketplace/actions/checkout123 # https://github.com/marketplace/actions/checkout
@@ -109,15 +129,26 @@ jobs:
109 uses: actions/labeler@v5.0.0129 uses: actions/labeler@v5.0.0
110 with:130 with:
111 configuration-path: .github/pr-auto-labels-by-branch.yml131 configuration-path: .github/pr-auto-labels-by-branch.yml
112 repo-token: ${{ secrets.GITHUB_TOKEN }}132 repo-token: ${{ steps.app.outputs.token }}
113133
114 label-by-files:134 label-by-files:
115 name: 🏷️ Label PR by Files135 name: 🏷️ Label PR by Files
136 needs: [label-by-branches]
116 runs-on: ubuntu-latest137 runs-on: ubuntu-latest
117 # Only needs to run when code is changed138 # Only needs to run when code is changed
118 if: github.event.action == 'opened' || github.event.action == 'synchronize'139 if: always() && (github.event.action == 'opened' || github.event.action == 'synchronize')
119140
120 steps:141 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
121 - name: Checkout Repository152 - name: Checkout Repository
122 # Checkout153 # Checkout
123 # https://github.com/marketplace/actions/checkout154 # https://github.com/marketplace/actions/checkout
@@ -127,15 +158,18 @@ jobs:
127 # Pull Request Labeler158 # Pull Request Labeler
128 # https://github.com/marketplace/actions/labeler159 # https://github.com/marketplace/actions/labeler
129 uses: actions/labeler@v5.0.0160 uses: actions/labeler@v5.0.0
161 env:
162 GITHUB_TOKEN: ${{ steps.app.outputs.token }} # labeler action needs some handholding
130 with:163 with:
131 configuration-path: .github/pr-auto-labels-by-files.yml164 configuration-path: .github/pr-auto-labels-by-files.yml
132 repo-token: ${{ secrets.GITHUB_TOKEN }}165 repo-token: ${{ steps.app.outputs.token }}
133166
134 remove-stale-label:167 remove-stale-label:
135 name: 🗑️ Remove Stale Label on Comment168 name: 🗑️ Remove Stale Label on Comment
169 needs: [label-by-branches, label-by-files]
136 runs-on: ubuntu-latest170 runs-on: ubuntu-latest
137 # Only runs on comments not done by the github actions bot171 # Only runs on comments not done by the github actions bot
138 if: github.event_name == 'pull_request_review_comment' && github.actor != 'github-actions[bot]'172 if: always() && (github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
139173
140 # Override permissions, issue labeler needs issues write access174 # Override permissions, issue labeler needs issues write access
141 permissions:175 permissions:
@@ -144,19 +178,33 @@ jobs:
144 pull-requests: write178 pull-requests: write
145179
146 steps:180 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
147 - name: Remove Stale Label194 - name: Remove Stale Label
195 if: always()
148 # 🤖 Issues Helper196 # 🤖 Issues Helper
149 # https://github.com/marketplace/actions/issues-helper197 # https://github.com/marketplace/actions/issues-helper
150 uses: actions-cool/issues-helper@v3.6.0198 uses: actions-cool/issues-helper@v3.6.0
151 with:199 with:
152 actions: 'remove-labels'200 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
154 issue-number: ${{ github.event.pull_request.number }}202 issue-number: ${{ github.event.pull_request.number }}
155 labels: '⚰️ Stale'203 labels: '⚰️ Stale'
156204
157 check-merge-blocking-labels:205 check-merge-blocking-labels:
158 name: 🚫 Check Merge Blocking Labels206 name: 🚫 Check Merge Blocking Labels
159 needs: [label-by-branches, label-by-files]207 needs: [label-by-branches, label-by-files, remove-stale-label]
160 runs-on: ubuntu-latest208 runs-on: ubuntu-latest
161 # Run, even if the previous jobs were skipped/failed209 # Run, even if the previous jobs were skipped/failed
162 if: always()210 if: always()
@@ -206,12 +254,22 @@ jobs:
206254
207 write-auto-comments:255 write-auto-comments:
208 name: 💬 Post PR Comments Based on Labels256 name: 💬 Post PR Comments Based on Labels
209 needs: [label-by-branches, label-by-files]257 needs: [label-by-branches, label-by-files, remove-stale-label, check-merge-blocking-labels]
210 runs-on: ubuntu-latest258 runs-on: ubuntu-latest
211 # Run, even if the previous jobs were skipped/failed259 # Run, even if the previous jobs were skipped/failed
212 if: always()260 if: always() && (github.event_name == 'pull_request_target')
213261
214 steps:262 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
215 - name: Checkout Repository273 - name: Checkout Repository
216 # Checkout274 # Checkout
217 # https://github.com/marketplace/actions/checkout275 # https://github.com/marketplace/actions/checkout
@@ -223,13 +281,17 @@ jobs:
223 uses: peaceiris/actions-label-commenter@v1.10.0281 uses: peaceiris/actions-label-commenter@v1.10.0
224 with:282 with:
225 config_file: .github/pr-auto-comments.yml283 config_file: .github/pr-auto-comments.yml
226 github_token: ${{ secrets.GITHUB_TOKEN }}284 github_token: ${{ steps.app.outputs.token }}
227285
228 # 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.286 # 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.
229 update-linked-issues:287 update-linked-issues:
230 name: 🔗 Mark Linked Issues Done on Staging Merge288 name: 🔗 Mark Linked Issues Done on Staging Merge
231 runs-on: ubuntu-latest289 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
234 # Override permissions, We need to be able to write to issues296 # Override permissions, We need to be able to write to issues
235 permissions:297 permissions:
@@ -238,6 +300,16 @@ jobs:
238 pull-requests: write300 pull-requests: write
239301
240 steps:302 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
241 - name: Extract Linked Issues From PR Description313 - name: Extract Linked Issues From PR Description
242 id: extract_issues314 id: extract_issues
243 run: |315 run: |
@@ -250,7 +322,7 @@ jobs:
250 PR_NUMBER=${{ github.event.pull_request.number }}322 PR_NUMBER=${{ github.event.pull_request.number }}
251 REPO=${{ github.repository }}323 REPO=${{ github.repository }}
252 API_URL="https://api.github.com/repos/$REPO/pulls/$PR_NUMBER/issues"324 API_URL="https://api.github.com/repos/$REPO/pulls/$PR_NUMBER/issues"
253 ISSUES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$API_URL" | jq -r '.[].number' | jq -R -s -c 'split("\n")[:-1]')325 ISSUES=$(curl -s -H "Authorization: token ${{ steps.app.outputs.token }}" "$API_URL" | jq -r '.[].number' | jq -R -s -c 'split("\n")[:-1]')
254 echo "linked_issues=$ISSUES" >> $GITHUB_ENV326 echo "linked_issues=$ISSUES" >> $GITHUB_ENV
255327
256 - name: Merge Issue Lists328 - name: Merge Issue Lists
@@ -262,7 +334,7 @@ jobs:
262 - name: Label Linked Issues334 - name: Label Linked Issues
263 id: label_linked_issues335 id: label_linked_issues
264 env:336 env:
265 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}337 GH_TOKEN: ${{ steps.app.outputs.token }}
266 run: |338 run: |
267 for ISSUE in $(echo $final_issues | jq -r '.[]'); do339 for ISSUE in $(echo $final_issues | jq -r '.[]'); do
268 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "✅ Done (staging)" --remove-label "🧑‍💻 In Progress"340 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:
15 check-merge-conflicts:15 check-merge-conflicts:
16 name: ⚔️ Check Merge Conflicts16 name: ⚔️ Check Merge Conflicts
17 runs-on: ubuntu-latest17 runs-on: ubuntu-latest
18 if: always()
1819
19 steps:20 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
20 - name: Check Merge Conflicts31 - name: Check Merge Conflicts
21 # Label Conflicting Pull Requests32 # Label Conflicting Pull Requests
22 # https://github.com/marketplace/actions/label-conflicting-pull-requests33 # https://github.com/marketplace/actions/label-conflicting-pull-requests
23 uses: eps1lon/actions-label-merge-conflict@v3.0.334 uses: eps1lon/actions-label-merge-conflict@v3.0.3
24 with:35 with:
25 dirtyLabel: '🚫 Merge Conflicts'36 dirtyLabel: '🚫 Merge Conflicts'
26 repoToken: ${{ secrets.GITHUB_TOKEN }}37 repoToken: ${{ steps.app.outputs.token }}
27 commentOnDirty: >38 commentOnDirty: >
28 ⚠️ This PR has conflicts that need to be resolved before it can be merged.39 ⚠️ 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
55.env55.env
56/StartDev.bat56/StartDev.bat
57yarn.lock57yarn.lock
58*.code-workspace
\ No newline at end of file58 \ No newline at end of file
CONTRIBUTING.md+13 -2
@@ -29,11 +29,22 @@
29 - Updating GitHub Actions.29 - Updating GitHub Actions.
30 - Hotfixing a critical bug.30 - Hotfixing a critical bug.
314. Project maintainers will test and can change your code before merging.314. Project maintainers will test and can change your code before merging.
325. 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:325. 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.
336. 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:
33 - What is the reason for a change?34 - What is the reason for a change?
34 - What did you do to achieve this?35 - What did you do to achieve this?
35 - How would a reviewer test the change?36 - How would a reviewer test the change?
366. 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.377. 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.
388. 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
42We 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
38## Further reading49## Further reading
3950
default/config.yaml+16 -4
@@ -82,6 +82,13 @@ requestProxy:
82enableUserAccounts: false82enableUserAccounts: false
83# Enable discreet login mode: hides user list on the login screen83# Enable discreet login mode: hides user list on the login screen
84enableDiscreetLogin: false84enableDiscreetLogin: false
85# If `basicAuthMode` and this are enabled then
86# the username and passwords for basic auth are the same as those
87# for the individual accounts
88perUserBasicAuth: false
89
90# -- SSO LOGIN CONFIGURATION --
91sso:
85 # Enable's authlia based auto login. Only enable this if you92 # Enable's authlia based auto login. Only enable this if you
86 # have setup and installed Authelia as a middle-ware on your93 # have setup and installed Authelia as a middle-ware on your
87 # reverse proxy94 # reverse proxy
@@ -90,10 +97,15 @@ enableDiscreetLogin: false
90 # as that used for authlia. (Ensure the username in authlia97 # as that used for authlia. (Ensure the username in authlia
91 # is an exact match in lowercase with that in sillytavern)98 # is an exact match in lowercase with that in sillytavern)
92 autheliaAuth: false99 autheliaAuth: false
93# If `basicAuthMode` and this are enabled then100 # Enable's authentik based auto login. Only enable this if you
94# the username and passwords for basic auth are the same as those101 # have setup and installed Authentik as a middle-ware on your
95# for the individual accounts102 # reverse proxy.
96perUserBasicAuth: false103 # 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
97# Host whitelist configuration. Recommended if you're using a listen mode109# Host whitelist configuration. Recommended if you're using a listen mode
98hostWhitelist:110hostWhitelist:
99 # Enable or disable host whitelisting111 # Enable or disable host whitelisting
default/content/presets/openai/Default.json+6 -3
@@ -1,20 +1,23 @@
1{1{
2 "chat_completion_source": "openai",2 "chat_completion_source": "openai",
3 "openai_model": "gpt-4-turbo",3 "openai_model": "gpt-4-turbo",
4 "claude_model": "claude-3-5-sonnet-20240620",4 "claude_model": "claude-sonnet-4-5",
5 "openrouter_model": "OR_Website",5 "openrouter_model": "OR_Website",
6 "openrouter_use_fallback": false,6 "openrouter_use_fallback": false,
7 "openrouter_group_models": false,7 "openrouter_group_models": false,
8 "openrouter_sort_models": "alphabetically",8 "openrouter_sort_models": "alphabetically",
9 "ai21_model": "jamba-large",9 "ai21_model": "jamba-large",
10 "mistralai_model": "mistral-large-latest",10 "mistralai_model": "mistral-large-latest",
11 "electronhub_model": "gpt-4o-mini",
12 "electronhub_sort_models": "alphabetically",
13 "electronhub_group_models": false,
11 "custom_model": "",14 "custom_model": "",
12 "custom_url": "",15 "custom_url": "",
13 "custom_include_body": "",16 "custom_include_body": "",
14 "custom_exclude_body": "",17 "custom_exclude_body": "",
15 "custom_include_headers": "",18 "custom_include_headers": "",
16 "google_model": "gemini-pro",19 "google_model": "gemini-2.5-pro",
17 "vertexai_model": "gemini-2.0-flash-001",20 "vertexai_model": "gemini-2.5-pro",
18 "temperature": 1,21 "temperature": 1,
19 "frequency_penalty": 0,22 "frequency_penalty": 0,
20 "presence_penalty": 0,23 "presence_penalty": 0,
default/content/settings.json+1 -1
@@ -620,7 +620,7 @@
620 },620 },
621 "wi_format": "{0}",621 "wi_format": "{0}",
622 "openai_model": "gpt-4-turbo",622 "openai_model": "gpt-4-turbo",
623 "claude_model": "claude-3-5-sonnet-20240620",623 "claude_model": "claude-sonnet-4-5",
624 "ai21_model": "jamba-large",624 "ai21_model": "jamba-large",
625 "openrouter_model": "OR_Website",625 "openrouter_model": "OR_Website",
626 "reverse_proxy": "",626 "reverse_proxy": "",
package-lock.json+2 -2
@@ -1,12 +1,12 @@
1{1{
2 "name": "sillytavern",2 "name": "sillytavern",
3 "version": "1.13.4",3 "version": "1.13.5",
4 "lockfileVersion": 3,4 "lockfileVersion": 3,
5 "requires": true,5 "requires": true,
6 "packages": {6 "packages": {
7 "": {7 "": {
8 "name": "sillytavern",8 "name": "sillytavern",
9 "version": "1.13.4",9 "version": "1.13.5",
10 "hasInstallScript": true,10 "hasInstallScript": true,
11 "license": "AGPL-3.0",11 "license": "AGPL-3.0",
12 "dependencies": {12 "dependencies": {
package.json+1 -1
@@ -113,7 +113,7 @@
113 "type": "git",113 "type": "git",
114 "url": "https://github.com/SillyTavern/SillyTavern.git"114 "url": "https://github.com/SillyTavern/SillyTavern.git"
115 },115 },
116 "version": "1.13.4",116 "version": "1.13.5",
117 "scripts": {117 "scripts": {
118 "start": "node server.js",118 "start": "node server.js",
119 "debug": "node --inspect server.js",119 "debug": "node --inspect server.js",
public/css/backgrounds.css+142 -69
@@ -1,6 +1,5 @@
1/* Main Page Backgrounds */1/* Main Page Backgrounds */
2#bg1,2#bg1 {
3#bg_custom {
4 background-repeat: no-repeat;3 background-repeat: no-repeat;
5 background-attachment: fixed;4 background-attachment: fixed;
6 background-size: cover;5 background-size: cover;
@@ -8,72 +7,46 @@
8 width: 100%;7 width: 100%;
9 height: 100%;8 height: 100%;
10 transition: background-image var(--animation-duration-3x) ease-in-out;9 transition: background-image var(--animation-duration-3x) ease-in-out;
10 z-index: -1;
11}11}
1212
13/* Fitting options */13/* Fitting options */
14#background_fitting {14#background_fitting {
15 max-width: 6em;15 max-width: 8em;
16}16}
1717
18/* Fill/Cover - scales to fill width while maintaining aspect ratio */18/* Fill/Cover - scales to fill width while maintaining aspect ratio */
19#bg1.cover,19#bg1.cover {
20#bg_custom.cover {
21 background-size: cover;20 background-size: cover;
22 background-position: center;21 background-position: center;
23}22}
2423
25/* Fit/Contain - shows entire image maintaining aspect ratio */24/* Fit/Contain - shows entire image maintaining aspect ratio */
26#bg1.contain,25#bg1.contain {
27#bg_custom.contain {
28 background-size: contain;26 background-size: contain;
29 background-position: center;27 background-position: center;
30 background-repeat: no-repeat;28 background-repeat: no-repeat;
31}29}
3230
33/* Stretch - stretches to fill entire space */31/* Stretch - stretches to fill entire space */
34#bg1.stretch,32#bg1.stretch {
35#bg_custom.stretch {
36 background-size: 100% 100%;33 background-size: 100% 100%;
37}34}
3835
39/* Center - centers without scaling */36/* Center - centers without scaling */
40#bg1.center,37#bg1.center {
41#bg_custom.center {
42 background-size: auto;38 background-size: auto;
43 background-position: center;39 background-position: center;
44 background-repeat: no-repeat;40 background-repeat: no-repeat;
45}41}
4642
47body.reduced-motion #bg1,
48body.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
66/* This is the main flex container for the entire drawer */43/* This is the main flex container for the entire drawer */
67#Backgrounds.drawer-content.openDrawer.bg-drawer-layout {44.drawer-content.openDrawer.bg-drawer-layout {
68 display: flex;45 display: flex;
46}
47
48.bg-drawer-layout {
69 flex-direction: column;49 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);
77 padding: 0;50 padding: 0;
78}51}
7952
@@ -82,11 +55,31 @@ body.reduced-motion #bg_custom {
82 padding: 5px;55 padding: 5px;
83 background-color: var(--SmartThemeBlurTintColor);56 background-color: var(--SmartThemeBlurTintColor);
84 border-bottom: 1px solid var(--SmartThemeBorderColor);57 border-bottom: 1px solid var(--SmartThemeBorderColor);
58 width: 100%;
85}59}
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%);
88 display: flex;82 display: flex;
89 align-items: center;
90 gap: 5px;83 gap: 5px;
91}84}
9285
@@ -94,49 +87,92 @@ body.reduced-motion #bg_custom {
94 flex-grow: 1;87 flex-grow: 1;
95 overflow-y: auto;88 overflow-y: auto;
96 overflow-x: hidden;89 overflow-x: hidden;
97 padding: 0 5px 5px;90 padding: 0 5px 15px;
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);
98}100}
99101
100#bg-filter {102#bg-filter {
101 font-size: calc(var(--mainFontSize) * 0.95);103 font-size: calc(var(--mainFontSize) * 0.95);
102}104}
103105
104/* Thumbnail Menu & Buttons */106/* Thumbnails */
107.bg_example:hover .BGSampleTitle {
108 opacity: 1;
109}
110
105.bg_example .mobile-only-menu-toggle {111.bg_example .mobile-only-menu-toggle {
106 display: none;112 display: none;
107}113}
108114
109.bg_example.flex-container {115.bg_example {
110 width: 30%;
111 max-width: 200px;
112 margin: 5px;
113 aspect-ratio: 16/9;
114 cursor: pointer;116 cursor: pointer;
115 box-shadow: 0 0 7px var(--black50a);117 box-shadow: 0 0 7px var(--black50a);
116
117 position: relative;118 position: relative;
118 overflow: hidden;119 overflow: hidden;
119 border-radius: 8px;120 border-radius: 8px;
120 border: 0px solid transparent;121 border: 0px solid transparent;
121 outline: 2px solid var(--SmartThemeBorderColor);122 outline: 2px solid var(--SmartThemeBorderColor);
122 outline-offset: -1px;123 outline-offset: -1px;
124
125 height: auto;
126 aspect-ratio: 16 / 9;
123}127}
124128
125.bg_example.flex-container:focus-visible {129.bg_example:focus-visible {
126 outline-offset: inherit;130 outline-offset: inherit;
131 outline-color: var(--interactable-outline-color);
127}132}
128133
129.bg_example_img {134.bg_example.locked-background {
135 outline: 2px solid var(--golden);
136 outline-offset: 0;
137}
138
139.bg_example.locked-background::after {
140 content: '\f023';
141 font-family: 'Font Awesome 6 Free';
142 font-weight: 900;
143
130 position: absolute;144 position: absolute;
131 top: -2px;145 bottom: 5px;
132 left: -2px;146 right: 5px;
133 right: -2px;147 z-index: 4;
134 bottom: -2px;148 color: var(--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;
140}176}
141177
142.bg_example .jg-menu {178.bg_example .jg-menu {
@@ -145,9 +181,8 @@ body.reduced-motion #bg_custom {
145 top: 2px;181 top: 2px;
146 right: 2px;182 right: 2px;
147 background-color: rgba(0, 0, 0, 0.5);183 background-color: rgba(0, 0, 0, 0.5);
148 border-radius: 8px;184 border-radius: 5px;
149 gap: 3px;185 padding: 3px 3px;
150 padding: 3px 5px;
151 z-index: 3;186 z-index: 3;
152 backdrop-filter: blur(4px);187 backdrop-filter: blur(4px);
153 border: 1px solid var(--SmartThemeBorderColor);188 border: 1px solid var(--SmartThemeBorderColor);
@@ -170,14 +205,14 @@ body.reduced-motion #bg_custom {
170205
171.bg_example .jg-button {206.bg_example .jg-button {
172 display: flex;207 display: flex;
173 width: 30px;208 width: 24px;
174 height: 30px;209 height: 24px;
175 align-items: center;210 align-items: center;
176 justify-content: center;211 justify-content: center;
177 color: white;212 color: white;
178 padding: 5px;213 padding: 5px;
179 font-size: 1.1em;214 font-size: 1.1em;
180 border-radius: 6px;215 border-radius: 5px;
181 transition: background-color var(--animation-duration) ease;216 transition: background-color var(--animation-duration) ease;
182}217}
183218
@@ -185,16 +220,54 @@ body.reduced-motion #bg_custom {
185 background-color: rgba(255, 255, 255, 0.2);220 background-color: rgba(255, 255, 255, 0.2);
186}221}
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;
190}243}
191244
192.bg_example.locked .jg-lock {245#bg-scroll-top.visible {
193 display: none;246 opacity: 1;
247 pointer-events: auto;
194}248}
195249
196.bg_example.locked .jg-unlock {250#bg-scroll-top:hover {
197 display: flex;251 filter: brightness(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;
198}271}
199272
200.bg_example:not([custom="true"]) .jg-copy,273.bg_example:not([custom="true"]) .jg-copy,
public/css/mobile-styles.css+31 -44
@@ -10,6 +10,18 @@
10 flex-basis: 100%;10 flex-basis: 100%;
11 }11 }
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
14 #send_form.compact #leftSendForm,26 #send_form.compact #leftSendForm,
15 #send_form.compact #rightSendForm {27 #send_form.compact #rightSendForm {
@@ -21,6 +33,15 @@
21 display: none;33 display: none;
22 }34 }
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
24 .bg_button {45 .bg_button {
25 font-size: 15px;46 font-size: 15px;
26 }47 }
@@ -40,6 +61,11 @@
40 z-index: 4;61 z-index: 4;
41 }62 }
4263
64 .bg_example.mobile-menu-open .mobile-only-menu-toggle {
65 opacity: 0;
66 pointer-events: none;
67 }
68
43 .bg_example .mobile-only-menu-toggle {69 .bg_example .mobile-only-menu-toggle {
44 display: flex;70 display: flex;
45 align-items: center;71 align-items: center;
@@ -57,48 +83,13 @@
57 backdrop-filter: blur(2px);83 backdrop-filter: blur(2px);
58 }84 }
5985
60 #bg-header-controls {86 .bg_example .jg-button {
61 flex-wrap: wrap;87 width: 30px;
62 row-gap: 10px;88 height: 30px;
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;
93 }89 }
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;
102 }93 }
10394
104 #Backgrounds.drawer-content.openDrawer.bg-drawer-layout {95 #Backgrounds.drawer-content.openDrawer.bg-drawer-layout {
@@ -429,10 +420,6 @@
429 .horde_multiple_hint {420 .horde_multiple_hint {
430 display: none;421 display: none;
431 }422 }
432
433 .bg_list {
434 width: unset;
435 }
436}423}
437424
438/*landscape mode phones and ipads*/425/*landscape mode phones and ipads*/
public/css/world-info.css+4 -0
@@ -301,6 +301,10 @@ select.keyselect+span.select2-container .select2-selection--multiple {
301 display: none;301 display: none;
302}302}
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
304.world_entry label[for="__invisible"] {308.world_entry label[for="__invisible"] {
305 visibility: hidden;309 visibility: hidden;
306 pointer-events: none;310 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+126 -157
@@ -49,7 +49,6 @@
4949
50<body class="no-blur">50<body class="no-blur">
51 <div id="preloader"></div>51 <div id="preloader"></div>
52 <div id="bg_custom"></div>
53 <div id="bg1"></div>52 <div id="bg1"></div>
54 <div id="character_context_menu" class="hidden">53 <div id="character_context_menu" class="hidden">
55 <ul>54 <ul>
@@ -254,7 +253,7 @@
254 </div>253 </div>
255 <div id="common-gen-settings-block" class="width100p">254 <div id="common-gen-settings-block" class="width100p">
256 <div id="pro-settings-block" class="flex-container gap10h5v justifyCenter">255 <div id="pro-settings-block" class="flex-container gap10h5v justifyCenter">
257 <div id="amount_gen_block" class="alignitemscenter flex-container marginBot5 flexFlowColumn flexBasis48p flexGrow flexShrink gap0">256 <div id="amount_gen_block" class="range-block-range-and-counter alignitemscenter flex-container marginBot5 flexFlowColumn flexBasis48p flexGrow flexShrink gap0">
258 <small data-i18n="response legth(tokens)">Response (tokens)</small>257 <small data-i18n="response legth(tokens)">Response (tokens)</small>
259 <input class="neo-range-slider" type="range" id="amount_gen" name="volume" min="16" max="2048" step="1">258 <input class="neo-range-slider" type="range" id="amount_gen" name="volume" min="16" max="2048" step="1">
260 <div data-randomization-disabled="true" class="wide100p">259 <div data-randomization-disabled="true" class="wide100p">
@@ -285,7 +284,7 @@
285 </label>284 </label>
286 </div>285 </div>
287 </div>286 </div>
288 <div id="max_context_block" class="alignitemscenter flex-container marginBot5 flexFlowColumn flexBasis48p flexGrow flexShrink gap0">287 <div id="max_context_block" class="range-block-range-and-counter alignitemscenter flex-container marginBot5 flexFlowColumn flexBasis48p flexGrow flexShrink gap0">
289 <small data-i18n="context size(tokens)">Context (tokens)</small>288 <small data-i18n="context size(tokens)">Context (tokens)</small>
290 <input class="neo-range-slider" type="range" id="max_context" name="volume" min="512" max="8192" step="64">289 <input class="neo-range-slider" type="range" id="max_context" name="volume" min="512" max="8192" step="64">
291 <div data-randomization-disabled="true" class="wide100p">290 <div data-randomization-disabled="true" class="wide100p">
@@ -649,7 +648,7 @@
649 Max Response Length (tokens)648 Max Response Length (tokens)
650 </div>649 </div>
651 <div class="wide100p">650 <div class="wide100p">
652 <input type="number" id="openai_max_tokens" name="openai_max_tokens" class="text_pole" min="1" max="65536">651 <input type="number" id="openai_max_tokens" name="openai_max_tokens" class="text_pole" min="1" max="65536" step="1">
653 </div>652 </div>
654 </div>653 </div>
655 <div class="range-block" data-source="openai,custom,xai,aimlapi,moonshot,azure_openai">654 <div class="range-block" data-source="openai,custom,xai,aimlapi,moonshot,azure_openai">
@@ -676,6 +675,9 @@
676 <div data-source="openrouter">675 <div data-source="openrouter">
677 <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="openrouter_max_prompt_cost" data-i18n="Unknown">Unknown</span>676 <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="openrouter_max_prompt_cost" data-i18n="Unknown">Unknown</span>
678 </div>677 </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>
679 <hr>681 <hr>
680 <div class="range-block">682 <div class="range-block">
681 <label for="stream_toggle" title="Enable OpenAI completion streaming" data-i18n="[title]Enable OpenAI completion streaming" class="checkbox_label widthFreeExpand">683 <label for="stream_toggle" title="Enable OpenAI completion streaming" data-i18n="[title]Enable OpenAI completion streaming" class="checkbox_label widthFreeExpand">
@@ -1463,14 +1465,12 @@
1463 </div>1465 </div>
1464 </div>1466 </div>
1465 <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">1467 <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">
1466 <div class="range-block-title textAlignCenter">1468 <div class="range-block-title textAlignCenter flex-container justifyCenter alignitemscenter">
1467 <small data-i18n="Sequence Breakers">Sequence Breakers</small>1469 <small data-i18n="Sequence Breakers">Sequence Breakers</small>
1468 </div>1470 </div>
1469 <div class="wide100p">
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 <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>1472 </div>
1472 </div>1473 </div>
1473 </div>
1474 <div data-tg-type="ooba, mancer, koboldcpp, tabby, llamacpp, aphrodite" id="dynatemp_block_ooba" class="wide100p">1474 <div data-tg-type="ooba, mancer, koboldcpp, tabby, llamacpp, aphrodite" id="dynatemp_block_ooba" class="wide100p">
1475 <h4 class="wide100p textAlignCenter">1475 <h4 class="wide100p textAlignCenter">
1476 <div class="flex-container alignitemscenter justifyCenter">1476 <div class="flex-container alignitemscenter justifyCenter">
@@ -1502,7 +1502,7 @@
1502 <div data-tg-type="ooba,infermaticai,koboldcpp,llamacpp,tabby" id="mirostat_block_ooba" class="wide100p">1502 <div data-tg-type="ooba,infermaticai,koboldcpp,llamacpp,tabby" id="mirostat_block_ooba" class="wide100p">
1503 <h4 class="wide100p textAlignCenter">1503 <h4 class="wide100p textAlignCenter">
1504 <label data-i18n="Mirostat (mode=1 is only for llama.cpp)">Mirostat</label>1504 <label data-i18n="Mirostat (mode=1 is only for llama.cpp)">Mirostat</label>
1505 <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."></div>1505 <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>
1506 </h4>1506 </h4>
1507 <div class="flex-container flexFlowRow gap10px flexShrink">1507 <div class="flex-container flexFlowRow gap10px flexShrink">
1508 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">1508 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
@@ -1511,18 +1511,12 @@
1511 <input class="neo-range-input" type="number" min="0" max="2" step="1" data-for="mirostat_mode_textgenerationwebui" id="mirostat_mode_counter_textgenerationwebui">1511 <input class="neo-range-input" type="number" min="0" max="2" step="1" data-for="mirostat_mode_textgenerationwebui" id="mirostat_mode_counter_textgenerationwebui">
1512 </div>1512 </div>
1513 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">1513 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
1514 <label>
1515 <small data-i18n="Mirostat Tau">Tau</small>1514 <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>
1518 <input class="neo-range-slider" type="range" id="mirostat_tau_textgenerationwebui" name="volume" min="0" max="20" step="0.01" />1515 <input class="neo-range-slider" type="range" id="mirostat_tau_textgenerationwebui" name="volume" min="0" max="20" step="0.01" />
1519 <input class="neo-range-input" type="number" min="0" max="20" step="0.01" data-for="mirostat_tau_textgenerationwebui" id="mirostat_tau_counter_textgenerationwebui">1516 <input class="neo-range-input" type="number" min="0" max="20" step="0.01" data-for="mirostat_tau_textgenerationwebui" id="mirostat_tau_counter_textgenerationwebui">
1520 </div>1517 </div>
1521 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">1518 <div class="alignitemscenter flex-container marginBot5 flexFlowColumn flexGrow flexShrink gap0">
1522 <label>
1523 <small data-i18n="Mirostat Eta">Eta</small>1519 <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>
1526 <input class="neo-range-slider" type="range" id="mirostat_eta_textgenerationwebui" name="volume" min="0" max="1" step="0.01" />1520 <input class="neo-range-slider" type="range" id="mirostat_eta_textgenerationwebui" name="volume" min="0" max="1" step="0.01" />
1527 <input class="neo-range-input" type="number" min="0" max="1" step="0.01" data-for="mirostat_eta_textgenerationwebui" id="mirostat_eta_counter_textgenerationwebui">1521 <input class="neo-range-input" type="number" min="0" max="1" step="0.01" data-for="mirostat_eta_textgenerationwebui" id="mirostat_eta_counter_textgenerationwebui">
1528 </div>1522 </div>
@@ -1583,18 +1577,14 @@
1583 </label>1577 </label>
1584 <label data-tg-type="ooba, tabby" class="checkbox_label flexGrow flexShrink" for="add_bos_token_textgenerationwebui">1578 <label data-tg-type="ooba, tabby" class="checkbox_label flexGrow flexShrink" for="add_bos_token_textgenerationwebui">
1585 <input type="checkbox" id="add_bos_token_textgenerationwebui" />1579 <input type="checkbox" id="add_bos_token_textgenerationwebui" />
1586 <label>
1587 <small data-i18n="Add BOS Token">Add BOS Token</small>1580 <small data-i18n="Add BOS Token">Add BOS Token</small>
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>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>
1589 </label>1582 </label>
1590 </label>
1591 <label data-tg-type="ooba, llamacpp, tabby, koboldcpp, dreamgen" class="checkbox_label flexGrow flexShrink" for="ban_eos_token_textgenerationwebui">1583 <label data-tg-type="ooba, llamacpp, tabby, koboldcpp, dreamgen" class="checkbox_label flexGrow flexShrink" for="ban_eos_token_textgenerationwebui">
1592 <input type="checkbox" id="ban_eos_token_textgenerationwebui" />1584 <input type="checkbox" id="ban_eos_token_textgenerationwebui" />
1593 <label>
1594 <small data-i18n="Ban EOS Token">Ban EOS Token</small>1585 <small data-i18n="Ban EOS Token">Ban EOS Token</small>
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>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>
1596 </label>1587 </label>
1597 </label>
1598 <label data-tg-type="vllm, aphrodite, infermaticai" class="checkbox_label" for="ignore_eos_token_textgenerationwebui">1588 <label data-tg-type="vllm, aphrodite, infermaticai" class="checkbox_label" for="ignore_eos_token_textgenerationwebui">
1599 <input type="checkbox" id="ignore_eos_token_textgenerationwebui" />1589 <input type="checkbox" id="ignore_eos_token_textgenerationwebui" />
1600 <small data-i18n="Ignore EOS Token">Ignore EOS Token</small>1590 <small data-i18n="Ignore EOS Token">Ignore EOS Token</small>
@@ -1610,19 +1600,14 @@
1610 </label>1600 </label>
1611 <label data-tg-type="ooba, aphrodite, tabby" class="checkbox_label flexGrow flexShrink" for="temperature_last_textgenerationwebui">1601 <label data-tg-type="ooba, aphrodite, tabby" class="checkbox_label flexGrow flexShrink" for="temperature_last_textgenerationwebui">
1612 <input type="checkbox" id="temperature_last_textgenerationwebui" />1602 <input type="checkbox" id="temperature_last_textgenerationwebui" />
1613 <label>
1614 <small data-i18n="Temperature Last">Temperature Last</small>1603 <small data-i18n="Temperature Last">Temperature Last</small>
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>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>
1616 </label>1605 </label>
1617 </label>
1618 <label data-tg-type="tabby" class="checkbox_label flexGrow flexShrink" for="speculative_ngram_textgenerationwebui">1606 <label data-tg-type="tabby" class="checkbox_label flexGrow flexShrink" for="speculative_ngram_textgenerationwebui">
1619 <input type="checkbox" id="speculative_ngram_textgenerationwebui" />1607 <input type="checkbox" id="speculative_ngram_textgenerationwebui" />
1620 <label>
1621 <small data-i18n="Speculative Ngram">Speculative Ngram</small>1608 <small data-i18n="Speculative Ngram">Speculative Ngram</small>
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>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>
1623 </label>1610 </label>
1624 </label>
1625
1626 <label data-tg-type="vllm, aphrodite, infermaticai" class="checkbox_label" for="spaces_between_special_tokens_textgenerationwebui">1611 <label data-tg-type="vllm, aphrodite, infermaticai" class="checkbox_label" for="spaces_between_special_tokens_textgenerationwebui">
1627 <input type="checkbox" id="spaces_between_special_tokens_textgenerationwebui" />1612 <input type="checkbox" id="spaces_between_special_tokens_textgenerationwebui" />
1628 <small data-i18n="Spaces Between Special Tokens">Spaces Between Special Tokens</small>1613 <small data-i18n="Spaces Between Special Tokens">Spaces Between Special Tokens</small>
@@ -1663,9 +1648,11 @@
1663 </div>1648 </div>
1664 </div>1649 </div>
1665 </div>1650 </div>
1666 <div class="range-block wide100p">1651 <div class="range-block wide100p" id="logit_bias_block_ooba">
1667 <div id="logit_bias_textgenerationwebui" class="range-block-title title_restorable">1652 <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>
1669 <div id="textgen_logit_bias_new_entry" class="menu_button menu_button_icon">1656 <div id="textgen_logit_bias_new_entry" class="menu_button menu_button_icon">
1670 <i class="fa-xs fa-solid fa-plus"></i>1657 <i class="fa-xs fa-solid fa-plus"></i>
1671 <small data-i18n="Add">Add</small>1658 <small data-i18n="Add">Add</small>
@@ -1678,7 +1665,7 @@
1678 <div class="logit_bias_list"></div>1665 <div class="logit_bias_list"></div>
1679 </div>1666 </div>
1680 </div>1667 </div>
1681 <div id="cfg_block_ooba" data-tg-type="ooba, tabby" class="wide100p">1668 <div id="cfg_block_ooba" data-tg-type="ooba, tabby" class="flex-container flexFlowColumn wide100p">
1682 <hr class="width100p">1669 <hr class="width100p">
1683 <h4 class="textAlignCenter">1670 <h4 class="textAlignCenter">
1684 <span data-i18n="CFG">CFG</span>1671 <span data-i18n="CFG">CFG</span>
@@ -1690,29 +1677,23 @@
1690 <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">1677 <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">
1691 </div>1678 </div>
1692 <div class="range-block">1679 <div class="range-block">
1693 <div class="range-block-title justifyLeft">1680 <div class="alignitemscenter justifyCenter flex-container flexShrink">
1694 <span data-i18n="Negative Prompt">Negative Prompt</span>1681 <small data-i18n="Negative Prompt">Negative Prompt</small>
1695 <small>
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>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>
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>
1701 </div>1683 </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>
1702 </div>1685 </div>
1703 </div>1686 </div>
1704 <div id="grammar_block_ooba" data-tg-type="ooba,aphrodite" class="wide100p">1687 <div id="grammar_block_ooba" data-tg-type="ooba,aphrodite,tabby" class="wide100p">
1705 <hr class="wide100p">1688 <hr class="wide100p">
1706 <h4 class="wide100p textAlignCenter">1689 <h4 class="wide100p textAlignCenter">
1707 <label>1690 <div class="flex-container justifyCenter alignitemscenter">
1708 <span data-i18n="Grammar String">Grammar String</span>1691 <span data-i18n="Grammar String">Grammar String</span>
1709 <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>1692 <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>
1710 <a href="https://github.com/ggerganov/llama.cpp/blob/master/grammars/README.md" target="_blank">1693 <a href="https://github.com/ggerganov/llama.cpp/blob/master/grammars/README.md" target="_blank">
1711 <small>
1712 <div class="fa-solid fa-up-right-from-square note-link-span"></div>1694 <div class="fa-solid fa-up-right-from-square note-link-span"></div>
1713 </small>
1714 </a>1695 </a>
1715 </label>1696 </div>
1716 </h4>1697 </h4>
1717 <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>1698 <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>
1718 </div>1699 </div>
@@ -2077,7 +2058,7 @@
2077 </span>2058 </span>
2078 </div>2059 </div>
2079 </div>2060 </div>
2080 <div class="range-block" data-source="deepseek,aimlapi,openrouter,custom,claude,xai,makersuite,vertexai,pollinations,moonshot,mistralai,fireworks,cometapi,electronhub,azure_openai">2061 <div class="range-block" data-source="deepseek,aimlapi,openrouter,custom,claude,xai,makersuite,vertexai,pollinations,moonshot,mistralai,fireworks,cometapi,electronhub,azure_openai,nanogpt">
2081 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">2062 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
2082 <input id="openai_show_thoughts" type="checkbox" />2063 <input id="openai_show_thoughts" type="checkbox" />
2083 <span data-i18n="Request model reasoning">Request model reasoning</span>2064 <span data-i18n="Request model reasoning">Request model reasoning</span>
@@ -2144,7 +2125,7 @@
2144 </div>2125 </div>
2145 </div>2126 </div>
2146 </div>2127 </div>
2147 <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom,azure_openai">2128 <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom,electronhub,azure_openai">
2148 <div id="logit_bias_openai" class="range-block-title openai_restorable" data-i18n="Logit Bias">2129 <div id="logit_bias_openai" class="range-block-title openai_restorable" data-i18n="Logit Bias">
2149 Logit Bias2130 Logit Bias
2150 </div>2131 </div>
@@ -3035,6 +3016,10 @@
3035 <h4 data-i18n="Claude Model">Claude Model</h4>3016 <h4 data-i18n="Claude Model">Claude Model</h4>
3036 <select id="model_claude_select">3017 <select id="model_claude_select">
3037 <optgroup label="Versions">3018 <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>
3038 <option value="claude-opus-4-1">claude-opus-4-1</option>3023 <option value="claude-opus-4-1">claude-opus-4-1</option>
3039 <option value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>3024 <option value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>
3040 <option value="claude-opus-4-0">claude-opus-4-0</option>3025 <option value="claude-opus-4-0">claude-opus-4-0</option>
@@ -3082,7 +3067,7 @@
3082 <div class="marginTopBot5">3067 <div class="marginTopBot5">
3083 <div class="inline-drawer wide100p">3068 <div class="inline-drawer wide100p">
3084 <div class="inline-drawer-toggle inline-drawer-header">3069 <div class="inline-drawer-toggle inline-drawer-header">
3085 <b data-i18n="Model Order">OpenRouter Model Sorting</b>3070 <b data-i18n="OpenRouter Model Sorting">OpenRouter Model Sorting</b>
3086 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>3071 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
3087 </div>3072 </div>
3088 <div class="inline-drawer-content m-b-1">3073 <div class="inline-drawer-content m-b-1">
@@ -3174,46 +3159,32 @@
3174 <option value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>3159 <option value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
3175 <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>3160 <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
3176 <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>3161 <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>
3178 <option value="gemini-2.5-flash">gemini-2.5-flash</option>3162 <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>
3179 <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>3164 <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>
3181 <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>3165 <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>
3182 <option value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>3167 <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>
3183 <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>3169 <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
3184 </optgroup>3170 </optgroup>
3185 <optgroup label="Gemini 2.0">3171 <optgroup label="Gemini 2.0">
3186 <option value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-pro-exp-03-25</option>3172 <option value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-exp-03-25</option>
3187 <option value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-pro-exp-03-25</option>3173 <option value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-exp-03-25</option>
3188 <option value="gemini-exp-1206">gemini-exp-1206 → 2.5-pro-exp-03-25</option>3174 <option value="gemini-exp-1206">gemini-exp-1206 → 2.5-exp-03-25</option>
3189 <option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>3175 <option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
3190 <option value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>3176 <option value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>
3191 <option value="gemini-2.0-flash-preview-image-generation">gemini-2.0-flash-preview-image-generation</option>3177 <option value="gemini-2.0-flash-preview-image-generation">gemini-2.0-flash-preview-image-generation</option>
3192 <option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>3178 <option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
3193 <option value="gemini-2.0-flash">gemini-2.0-flash</option>3179 <option value="gemini-2.0-flash">gemini-2.0-flash</option>
3194 <option value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21 → 2.5-flash-preview-04-17</option>3180 <option value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21 → 2.5-flash-preview-05-20</option>
3195 <option value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-04-17</option>3181 <option value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-05-20</option>
3196 <option value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-04-17</option>3182 <option value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-05-20</option>
3197 <option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>3183 <option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
3198 <option value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>3184 <option value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>
3199 <option value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>3185 <option value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>
3200 <option value="gemini-2.0-flash-lite">gemini-2.0-flash-lite</option>3186 <option value="gemini-2.0-flash-lite">gemini-2.0-flash-lite</option>
3201 </optgroup>3187 </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>
3217 <optgroup label="Gemma">3188 <optgroup label="Gemma">
3218 <option value="gemma-3n-e4b-it">gemma-3n-e4b-it</option>3189 <option value="gemma-3n-e4b-it">gemma-3n-e4b-it</option>
3219 <option value="gemma-3n-e2b-it">gemma-3n-e2b-it</option>3190 <option value="gemma-3n-e2b-it">gemma-3n-e2b-it</option>
@@ -3225,6 +3196,9 @@
3225 <optgroup label="LearnLM">3196 <optgroup label="LearnLM">
3226 <option value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>3197 <option value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>
3227 </optgroup>3198 </optgroup>
3199 <optgroup label="Robotics-ER">
3200 <option value="gemini-robotics-er-1.5-preview">gemini-robotics-er-1.5-preview</option>
3201 </optgroup>
3228 <optgroup id="google_other_models" label="Other"></optgroup>3202 <optgroup id="google_other_models" label="Other"></optgroup>
3229 </select>3203 </select>
3230 </div>3204 </div>
@@ -3350,15 +3324,9 @@
3350 <!-- data-mode="full" is for models that require a service account -->3324 <!-- data-mode="full" is for models that require a service account -->
3351 <optgroup label="Gemini 2.5">3325 <optgroup label="Gemini 2.5">
3352 <option value="gemini-2.5-pro">gemini-2.5-pro</option>3326 <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>
3357 <option value="gemini-2.5-flash">gemini-2.5-flash</option>3327 <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>
3360 <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>3328 <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
3361 <option value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>3329 <option value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
3362 <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>3330 <option value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
3363 </optgroup>3331 </optgroup>
3364 <optgroup label="Gemini 2.0">3332 <optgroup label="Gemini 2.0">
@@ -3383,67 +3351,7 @@
3383 <div>3351 <div>
3384 <h4 data-i18n="MistralAI Model">MistralAI Model</h4>3352 <h4 data-i18n="MistralAI Model">MistralAI Model</h4>
3385 <select id="model_mistralai_select">3353 <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>
3447 </select>3355 </select>
3448 </div>3356 </div>
3449 </form>3357 </form>
@@ -3473,6 +3381,9 @@
3473 </div>3381 </div>
3474 <div id="electronhub_form" data-source="electronhub">3382 <div id="electronhub_form" data-source="electronhub">
3475 <h4 data-i18n="Electron Hub API Key">Electron Hub API Key</h4>3383 <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>
3476 <div class="flex-container">3387 <div class="flex-container">
3477 <input id="api_key_electronhub" name="api_key_electronhub" class="text_pole flex1" value="" type="text" autocomplete="off">3388 <input id="api_key_electronhub" name="api_key_electronhub" class="text_pole flex1" value="" type="text" autocomplete="off">
3478 <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>3389 <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,11 +3391,44 @@
3480 <div data-for="api_key_electronhub" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">3391 <div data-for="api_key_electronhub" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3481 For privacy reasons, your API key will be hidden after you click 'Connect'.3392 For privacy reasons, your API key will be hidden after you click 'Connect'.
3482 </div>3393 </div>
3394 <div>
3483 <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>3395 <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>
3484 <select id="model_electronhub_select">3396 <select id="model_electronhub_select">
3485 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>3397 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
3486 </select>3398 </select>
3487 </div>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>
3431 </div>
3488 <div id="nanogpt_form" data-source="nanogpt">3432 <div id="nanogpt_form" data-source="nanogpt">
3489 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>3433 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
3490 <div class="flex-container">3434 <div class="flex-container">
@@ -3666,19 +3610,13 @@
3666 <div data-for="api_key_xai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">3610 <div data-for="api_key_xai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3667 For privacy reasons, your API key will be hidden after you click 'Connect'.3611 For privacy reasons, your API key will be hidden after you click 'Connect'.
3668 </div>3612 </div>
3613 <div>
3669 <h4 data-i18n="xAI Model">xAI Model</h4>3614 <h4 data-i18n="xAI Model">xAI Model</h4>
3670 <select id="model_xai_select">3615 <select id="model_xai_select">
3671 <option value="grok-4-0709">grok-4-0709</option>3616 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
3672 <option value="grok-3-beta">grok-3-beta</option>
3673 <option value="grok-3-fast-beta">grok-3-fast-beta</option>
3674 <option value="grok-3-mini-beta">grok-3-mini-beta</option>
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>3617 </select>
3681 </div>3618 </div>
3619 </div>
3682 <div id="aimlapi_form" data-source="aimlapi">3620 <div id="aimlapi_form" data-source="aimlapi">
3683 <h4>3621 <h4>
3684 <span data-i18n="AI/ML API Key">AI/ML API Key</span>3622 <span data-i18n="AI/ML API Key">AI/ML API Key</span>
@@ -4279,7 +4217,7 @@
4279 Token Padding4217 Token Padding
4280 </small>4218 </small>
4281 </div>4219 </div>
4282 <input id="token_padding" class="text_pole textarea_compact" type="number" min="-2048" max="2048" />4220 <input id="token_padding" class="text_pole textarea_compact" type="number" min="-2048" max="2048" step="1" />
4283 </div>4221 </div>
4284 </div>4222 </div>
4285 <div>4223 <div>
@@ -4959,7 +4897,6 @@
4959 <small data-i18n="Smooth Streaming">4897 <small data-i18n="Smooth Streaming">
4960 Smooth Streaming4898 Smooth Streaming
4961 </small>4899 </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>
4963 </div>4900 </div>
4964 <div id="smooth_streaming_speed_control" class="flexBasis100p wide100p">4901 <div id="smooth_streaming_speed_control" class="flexBasis100p wide100p">
4965 <input type="range" id="smooth_streaming_speed" name="smooth_streaming_speed" min="0" max="100" step="10" value="50">4902 <input type="range" id="smooth_streaming_speed" name="smooth_streaming_speed" min="0" max="100" step="10" value="50">
@@ -4970,6 +4907,11 @@
4970 </div>4907 </div>
4971 </div>4908 </div>
4972 </label>4909 </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
4974 <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">4916 <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">
4975 <input id="play_message_sound" type="checkbox" />4917 <input id="play_message_sound" type="checkbox" />
@@ -5339,15 +5281,18 @@
5339 </div>5281 </div>
5340 </div>5282 </div>
5341 </div>5283 </div>
5342 <div id="logo_block" class="drawer">5284 <div id="backgrounds-button" class="drawer">
5343 <div id="site_logo" class="drawer-toggle drawer-header" title="Change Background Image" data-i18n="[title]Change Background Image">5285 <div id="backgrounds-drawer-toggle" class="drawer-toggle drawer-header" title="Change Background Image" data-i18n="[title]Change Background Image">
5344 <div class="drawer-icon fa-solid fa-panorama fa-fw closedIcon"></div>5286 <div class="drawer-icon fa-solid fa-panorama fa-fw closedIcon"></div>
5345 </div>5287 </div>
5346 <div id="Backgrounds" class="drawer-content closedDrawer bg-drawer-layout">5288 <div id="Backgrounds" class="drawer-content closedDrawer bg-drawer-layout">
5347 <div id="bg-header-fixed">5289 <div id="bg-header-fixed">
5348 <div class="flex-container alignItemsBaseline wide100p">5290 <div class="bg-header-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>
5351 <select id="background_fitting" class="text_pole" data-i18n="[title]Background Fitting" title="Background Fitting">5296 <select id="background_fitting" class="text_pole" data-i18n="[title]Background Fitting" title="Background Fitting">
5352 <option value="classic" data-i18n="Classic">Classic</option>5297 <option value="classic" data-i18n="Classic">Classic</option>
5353 <option value="cover" data-i18n="Cover">Cover</option>5298 <option value="cover" data-i18n="Cover">Cover</option>
@@ -5359,16 +5304,25 @@
5359 <i class="fa-solid fa-wand-magic"></i>5304 <i class="fa-solid fa-wand-magic"></i>
5360 <span data-i18n="Auto-select">Auto-select</span>5305 <span data-i18n="Auto-select">Auto-select</span>
5361 </div>5306 </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>
5363 <i class="fa-solid fa-plus"></i>5308 <div class="bg-header-row-2">
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>
5366 </div>5310 </div>
5367 </div>5311 </div>
5368 <div id="bg-scrollable-content">5312 <div id="bg-scrollable-content">
5369 <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter">5313 <div class="heading-container-with-controls">
5314 <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter heading-text">
5370 System Backgrounds5315 System Backgrounds
5371 </h3>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>
5372 <div id="bg_menu_content" class="bg_list">5326 <div id="bg_menu_content" class="bg_list">
5373 </div>5327 </div>
5374 <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">5328 <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">
@@ -5383,6 +5337,9 @@
5383 <form id="form_bg_upload" style="display: none;">5337 <form id="form_bg_upload" style="display: none;">
5384 <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*">5338 <input type="file" id="add_bg_button" name="avatar" accept="image/jpeg,image/png,image/gif,image/bmp,image/svg+xml,video/*">
5385 </form>5339 </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>
5386 </div>5343 </div>
5387 </div>5344 </div>
5388 <div id="extensions-settings-button" class="drawer">5345 <div id="extensions-settings-button" class="drawer">
@@ -5540,7 +5497,10 @@
5540 </div>5497 </div>
5541 </div>5498 </div>
55425499
5543 <h4 data-i18n="Persona Description">Persona Description</h4>5500 <h4 class="flex-container alignItemsBaseline">
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>
5544 <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>5504 <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
5546 <div class="flex-container justifySpaceBetween">5506 <div class="flex-container justifySpaceBetween">
@@ -6291,7 +6251,7 @@
6291 <div name="selectChatPopupHeader" class="flex-container alignitemscenter justifySpaceBetween flexGap10">6251 <div name="selectChatPopupHeader" class="flex-container alignitemscenter justifySpaceBetween flexGap10">
6292 <div id="select_chat_import"> <!-- import chat popup header -->6252 <div id="select_chat_import"> <!-- import chat popup header -->
6293 <form id="form_import_chat" action="javascript:void(null);" method="post" enctype="multipart/form-data" style="display: none;">6253 <form id="form_import_chat" action="javascript:void(null);" method="post" enctype="multipart/form-data" style="display: none;">
6294 <input type="file" id="chat_import_file" accept=".json, .jsonl" name="avatar">6254 <input type="file" id="chat_import_file" accept=".json, .jsonl" multiple name="avatar">
6295 <input id="chat_import_file_type" name="file_type" class="text_pole" value="" autocomplete="off" style="display: none;">6255 <input id="chat_import_file_type" name="file_type" class="text_pole" value="" autocomplete="off" style="display: none;">
6296 <input id="chat_import_avatar_url" name="avatar_url" class="text_pole" value="" autocomplete="off" style="display: none;">6256 <input id="chat_import_avatar_url" name="avatar_url" class="text_pole" value="" autocomplete="off" style="display: none;">
6297 <input id="chat_import_character_name" name="character_name" class="text_pole" value="" autocomplete="off" style="display: none;">6257 <input id="chat_import_character_name" name="character_name" class="text_pole" value="" autocomplete="off" style="display: none;">
@@ -6322,10 +6282,9 @@
6322 <i class="fa-solid fa-ellipsis-vertical"></i>6282 <i class="fa-solid fa-ellipsis-vertical"></i>
6323 </div>6283 </div>
6324 <div class="jg-menu">6284 <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) -->
6327 <div data-action="lock" class="jg-button jg-lock fa-solid fa-lock fa-fw pointer" data-i18n="[title]Lock" title="Lock"></div>6285 <div data-action="lock" class="jg-button jg-lock fa-solid fa-lock fa-fw pointer" data-i18n="[title]Lock" title="Lock"></div>
6328 <div data-action="unlock" class="jg-button jg-unlock fa-solid fa-lock-open fa-fw pointer" data-i18n="[title]Unlock" title="Unlock"></div>6286 <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>
6329 <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>6288 <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>
6330 <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>6289 <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>
6331 </div>6290 </div>
@@ -6448,6 +6407,13 @@
6448 </div>6407 </div>
6449 </div>6408 </div>
6450 <div name="perEntryOverridesBlock" class="flex-container wide100p alignitemscenter">6409 <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>
6451 <div class="world_entry_form_control flex1">6417 <div class="world_entry_form_control flex1">
6452 <small class="textAlignCenter" data-i18n="Scan Depth">Scan Depth</small>6418 <small class="textAlignCenter" data-i18n="Scan Depth">Scan Depth</small>
6453 <input class="text_pole margin0" name="scanDepth" type="number" placeholder="Use global setting" data-i18n="[placeholder]Use global setting" max="1000">6419 <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 @@
6785 <option value="4" data-role="2" data-i18n="at Depth AI">6751 <option value="4" data-role="2" data-i18n="at Depth AI">
6786 @D 🤖6752 @D 🤖
6787 </option>6753 </option>
6754 <option value="7" data-role="" data-i18n="Outlet">
6755 ➡️ Outlet
6756 </option>
6788 </select>6757 </select>
6789 </div>6758 </div>
6790 <div class="world_entry_form_control wi-enter-footer-text flex-container flexNoGap">6759 <div class="world_entry_form_control wi-enter-footer-text flex-container flexNoGap">
public/lib/pagination.js+69 -3
@@ -139,9 +139,9 @@
139 return el;139 return el;
140 },140 },
141141
142 getPageLinkTag: function(index) {142 getPageLinkTag: function(text) {
143 var pageLink = attributes.pageLink;143 var pageLink = attributes.pageLink;
144 return pageLink ? `<a href="${pageLink}">${index}</a>` : `<a>${index}</a>`;144 return pageLink ? `<a href="${pageLink}">${text}</a>` : `<a>${text}</a>`;
145 },145 },
146146
147 // Generate HTML for page numbers147 // Generate HTML for page numbers
@@ -233,6 +233,8 @@
233233
234 var prevText = attributes.prevText;234 var prevText = attributes.prevText;
235 var nextText = attributes.nextText;235 var nextText = attributes.nextText;
236 var firstText = attributes.firstText;
237 var lastText = attributes.lastText;
236 var goButtonText = attributes.goButtonText;238 var goButtonText = attributes.goButtonText;
237239
238 var classPrefix = attributes.classPrefix;240 var classPrefix = attributes.classPrefix;
@@ -240,6 +242,8 @@
240 var ulClassName = attributes.ulClassName || '';242 var ulClassName = attributes.ulClassName || '';
241 var prevClassName = attributes.prevClassName || '';243 var prevClassName = attributes.prevClassName || '';
242 var nextClassName = attributes.nextClassName || '';244 var nextClassName = attributes.nextClassName || '';
245 var firstClassName = attributes.firstClassName || '';
246 var lastClassName = attributes.lastClassName || '';
243247
244 var html = '';248 var html = '';
245 var sizeSelect = `<select class="J-paginationjs-size-select">`;249 var sizeSelect = `<select class="J-paginationjs-size-select">`;
@@ -295,9 +299,11 @@
295 if (showPrevious) {299 if (showPrevious) {
296 if (currentPage <= 1) {300 if (currentPage <= 1) {
297 if (!autoHidePrevious) {301 if (!autoHidePrevious) {
302 html += `<li class="${classPrefix}-first ${disableClassName} ${firstClassName}"><a>${firstText}</a></li>`;
298 html += `<li class="${classPrefix}-prev ${disableClassName} ${prevClassName}"><a>${prevText}</a></li>`;303 html += `<li class="${classPrefix}-prev ${disableClassName} ${prevClassName}"><a>${prevText}</a></li>`;
299 }304 }
300 } else {305 } else {
306 html += `<li class="${classPrefix}-first J-paginationjs-first ${firstClassName}" data-num="1" title="First page">${getPageLinkTag(firstText)}</li>`;
301 html += `<li class="${classPrefix}-prev J-paginationjs-previous ${prevClassName}" data-num="${currentPage - 1}" title="Previous page">${getPageLinkTag(prevText)}</li>`;307 html += `<li class="${classPrefix}-prev J-paginationjs-previous ${prevClassName}" data-num="${currentPage - 1}" title="Previous page">${getPageLinkTag(prevText)}</li>`;
302 }308 }
303 }309 }
@@ -312,9 +318,11 @@
312 if (currentPage >= totalPage) {318 if (currentPage >= totalPage) {
313 if (!autoHideNext) {319 if (!autoHideNext) {
314 html += `<li class="${classPrefix}-next ${disableClassName} ${nextClassName}"><a>${nextText}</a></li>`;320 html += `<li class="${classPrefix}-next ${disableClassName} ${nextClassName}"><a>${nextText}</a></li>`;
321 html += `<li class="${classPrefix}-last ${disableClassName} ${lastClassName}"><a>${lastText}</a></li>`;
315 }322 }
316 } else {323 } else {
317 html += `<li class="${classPrefix}-next J-paginationjs-next ${nextClassName}" data-num="${currentPage + 1}" title="Next page">${getPageLinkTag(nextText)}</li>`;324 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>`;
318 }326 }
319 }327 }
320 html += `</ul></div>`;328 html += `</ul></div>`;
@@ -542,6 +550,14 @@
542 this.go(this.model.pageNumber + 1, callback);550 this.go(this.model.pageNumber + 1, callback);
543 },551 },
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
545 disable: function() {561 disable: function() {
546 var self = this;562 var self = this;
547 var source = self.isAsync ? 'async' : 'sync';563 var source = self.isAsync ? 'async' : 'sync';
@@ -774,6 +790,38 @@
774 if (!attributes.pageLink) return false;790 if (!attributes.pageLink) return false;
775 });791 });
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
777 // Go button click listener825 // Go button click listener
778 el.on('click', '.J-paginationjs-go-button', function(event) {826 el.on('click', '.J-paginationjs-go-button', function(event) {
779 var pageNumber = $('.J-paginationjs-go-pagenumber', el).val();827 var pageNumber = $('.J-paginationjs-go-pagenumber', el).val();
@@ -833,6 +881,16 @@
833 self.next(done);881 self.next(done);
834 });882 });
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
836 // Disable894 // Disable
837 container.on(eventPrefix + 'disable', function() {895 container.on(eventPrefix + 'disable', function() {
838 self.disable();896 self.disable();
@@ -892,6 +950,8 @@
892 switch (options) {950 switch (options) {
893 case 'previous':951 case 'previous':
894 case 'next':952 case 'next':
953 case 'first':
954 case 'last':
895 case 'go':955 case 'go':
896 case 'disable':956 case 'disable':
897 case 'enable':957 case 'enable':
@@ -991,6 +1051,12 @@
991 // 'Next' text1051 // 'Next' text
992 nextText: '&rsaquo;',1052 nextText: '&rsaquo;',
9931053
1054 // 'First' text
1055 firstText: '&laquo;',
1056
1057 // 'Last' text
1058 lastText: '&raquo;',
1059
994 // Ellipsis text1060 // Ellipsis text
995 ellipsisText: '...',1061 ellipsisText: '...',
9961062
@@ -1149,7 +1215,7 @@
11491215
1150 // uninstall plugin1216 // uninstall plugin
1151 function uninstallPlugin(target) {1217 function uninstallPlugin(target) {
1152 var events = ['go', 'previous', 'next', 'disable', 'enable', 'refresh', 'show', 'hide', 'destroy'];1218 var events = ['go', 'previous', 'next', 'first', 'last', 'disable', 'enable', 'refresh', 'show', 'hide', 'destroy'];
11531219
1154 // off all events1220 // off all events
1155 $.each(events, function(index, value) {1221 $.each(events, function(index, value) {
public/locales/ar-sa.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "وحدة تحكم المطور في Anthropic",369 "Anthropic's developer console": "وحدة تحكم المطور في Anthropic",
370 "Claude Model": "نموذج Claude",370 "Claude Model": "نموذج Claude",
371 "Window AI Model": "نموذج Window AI",371 "Window AI Model": "نموذج Window AI",
372 "Model Order": "فرز نموذج OpenRouter",372 "OpenRouter Model Sorting": "فرز نموذج OpenRouter",
373 "Alphabetically": "أبجديا",373 "Alphabetically": "أبجديا",
374 "Price": "السعر (الأرخص)",374 "Price": "السعر (الأرخص)",
375 "Context Size": "حجم السياق",375 "Context Size": "حجم السياق",
public/locales/de-de.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "Anthropics Entwicklerkonsole",369 "Anthropic's developer console": "Anthropics Entwicklerkonsole",
370 "Claude Model": "Claude-Modell",370 "Claude Model": "Claude-Modell",
371 "Window AI Model": "Fenster AI-Modell",371 "Window AI Model": "Fenster AI-Modell",
372 "Model Order": "Sortierung des OpenRouter-Modells",372 "OpenRouter Model Sorting": "Sortierung des OpenRouter-Modells",
373 "Alphabetically": "Alphabetisch",373 "Alphabetically": "Alphabetisch",
374 "Price": "Preis (am günstigsten)",374 "Price": "Preis (am günstigsten)",
375 "Context Size": "Kontextgröße",375 "Context Size": "Kontextgröße",
public/locales/es-es.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "la consola de desarrolladores de Anthropic",369 "Anthropic's developer console": "la consola de desarrolladores de Anthropic",
370 "Claude Model": "Modelo de Claude",370 "Claude Model": "Modelo de Claude",
371 "Window AI Model": "Modelo de Window AI",371 "Window AI Model": "Modelo de Window AI",
372 "Model Order": "Clasificación de modelos de OpenRouter",372 "OpenRouter Model Sorting": "Clasificación de modelos de OpenRouter",
373 "Alphabetically": "Alfabéticamente",373 "Alphabetically": "Alfabéticamente",
374 "Price": "Precio (más barato)",374 "Price": "Precio (más barato)",
375 "Context Size": "Tamaño del contexto",375 "Context Size": "Tamaño del contexto",
public/locales/fr-fr.json+2 -2
@@ -348,7 +348,7 @@
348 "Anthropic's developer console": "Console de développement d'Anthropic",348 "Anthropic's developer console": "Console de développement d'Anthropic",
349 "Claude Model": "Modèle Claude",349 "Claude Model": "Modèle Claude",
350 "Window AI Model": "Modèle Window AI",350 "Window AI Model": "Modèle Window AI",
351 "Model Order": "Tri des modèles OpenRouter",351 "OpenRouter Model Sorting": "Tri des modèles OpenRouter",
352 "Alphabetically": "Alphabétiquement",352 "Alphabetically": "Alphabétiquement",
353 "Price": "Prix ​​(le moins cher)",353 "Price": "Prix ​​(le moins cher)",
354 "Context Size": "Taille du contexte",354 "Context Size": "Taille du contexte",
@@ -1566,7 +1566,7 @@
1566 "These files are available for all characters in the current chat.": "Ces fichiers sont disponibles pour tous les personnages du chat actuel.",1566 "These files are available for all characters in the current chat.": "Ces fichiers sont disponibles pour tous les personnages du chat actuel.",
1567 "Image Captioning": "Légende des images",1567 "Image Captioning": "Légende des images",
1568 "Local": "Local",1568 "Local": "Local",
1569 "Multimodal (OpenAI / Anthropic / llama / Google)": "Multimodal (OpenAI / Anthropic / llama / Google)",1569 "Multimodal": "Multimodal",
1570 "Extras": "Extras",1570 "Extras": "Extras",
1571 "Horde": "Horde",1571 "Horde": "Horde",
1572 "API": "API",1572 "API": "API",
public/locales/is-is.json+1 -1
@@ -367,7 +367,7 @@
367 "Anthropic's developer console": "Uppbyggingaraðilar Forritara stjórnborð",367 "Anthropic's developer console": "Uppbyggingaraðilar Forritara stjórnborð",
368 "Claude Model": "Claude módel",368 "Claude Model": "Claude módel",
369 "Window AI Model": "Vindauga AI módel",369 "Window AI Model": "Vindauga AI módel",
370 "Model Order": "OpenRouter líkanaflokkun",370 "OpenRouter Model Sorting": "OpenRouter líkanaflokkun",
371 "Alphabetically": "Stafrófsröð",371 "Alphabetically": "Stafrófsröð",
372 "Price": "Verð (ódýrast)",372 "Price": "Verð (ódýrast)",
373 "Context Size": "Samhengisstærð",373 "Context Size": "Samhengisstærð",
public/locales/it-it.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "Console dello sviluppatore di Anthropic",369 "Anthropic's developer console": "Console dello sviluppatore di Anthropic",
370 "Claude Model": "Modello Claude",370 "Claude Model": "Modello Claude",
371 "Window AI Model": "Modello AI di Window",371 "Window AI Model": "Modello AI di Window",
372 "Model Order": "Ordinamento dei modelli OpenRouter",372 "OpenRouter Model Sorting": "Ordinamento dei modelli OpenRouter",
373 "Alphabetically": "In ordine alfabetico",373 "Alphabetically": "In ordine alfabetico",
374 "Price": "Prezzo (il più economico)",374 "Price": "Prezzo (il più economico)",
375 "Context Size": "Dimensione del contesto",375 "Context Size": "Dimensione del contesto",
public/locales/ja-jp.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "Anthropicの開発者コンソール",369 "Anthropic's developer console": "Anthropicの開発者コンソール",
370 "Claude Model": "Claudeモデル",370 "Claude Model": "Claudeモデル",
371 "Window AI Model": "Window AIモデル",371 "Window AI Model": "Window AIモデル",
372 "Model Order": "OpenRouter モデルのソート",372 "OpenRouter Model Sorting": "OpenRouter モデルのソート",
373 "Alphabetically": "アルファベット順",373 "Alphabetically": "アルファベット順",
374 "Price": "価格(最安値)",374 "Price": "価格(最安値)",
375 "Context Size": "コンテキストサイズ",375 "Context Size": "コンテキストサイズ",
public/locales/ko-kr.json+1 -1
@@ -374,7 +374,7 @@
374 "Slack and Poe cookies will not work here, do not bother trying.": "Slack과 Poe 쿠키는 여기서 작동하지 않습니다. 시도하지 마세요.",374 "Slack and Poe cookies will not work here, do not bother trying.": "Slack과 Poe 쿠키는 여기서 작동하지 않습니다. 시도하지 마세요.",
375 "Claude Model": "Claude 모델",375 "Claude Model": "Claude 모델",
376 "Window AI Model": "Window AI 모델",376 "Window AI Model": "Window AI 모델",
377 "Model Order": "OpenRouter 모델 정렬",377 "OpenRouter Model Sorting": "OpenRouter 모델 정렬",
378 "Alphabetically": "알파벳순",378 "Alphabetically": "알파벳순",
379 "Price": "가격(가장 저렴)",379 "Price": "가격(가장 저렴)",
380 "Context Size": "컨텍스트 크기",380 "Context Size": "컨텍스트 크기",
public/locales/nl-nl.json+1 -1
@@ -367,7 +367,7 @@
367 "Anthropic's developer console": "Anthropic's ontwikkelaarsconsole",367 "Anthropic's developer console": "Anthropic's ontwikkelaarsconsole",
368 "Claude Model": "Claude-model",368 "Claude Model": "Claude-model",
369 "Window AI Model": "Window AI-model",369 "Window AI Model": "Window AI-model",
370 "Model Order": "Sorteren van OpenRouter-modellen",370 "OpenRouter Model Sorting": "Sorteren van OpenRouter-modellen",
371 "Alphabetically": "Alfabetisch",371 "Alphabetically": "Alfabetisch",
372 "Price": "Prijs (goedkoopste)",372 "Price": "Prijs (goedkoopste)",
373 "Context Size": "Contextgrootte",373 "Context Size": "Contextgrootte",
public/locales/pt-pt.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "console de desenvolvedor da Anthropic",369 "Anthropic's developer console": "console de desenvolvedor da Anthropic",
370 "Claude Model": "Modelo Claude",370 "Claude Model": "Modelo Claude",
371 "Window AI Model": "Modelo Window AI",371 "Window AI Model": "Modelo Window AI",
372 "Model Order": "Classificação de modelo OpenRouter",372 "OpenRouter Model Sorting": "Classificação de modelo OpenRouter",
373 "Alphabetically": "Alfabeticamente",373 "Alphabetically": "Alfabeticamente",
374 "Price": "Preço (mais barato)",374 "Price": "Preço (mais barato)",
375 "Context Size": "Tamanho do contexto",375 "Context Size": "Tamanho do contexto",
public/locales/ru-ru.json+121 -29
@@ -24,7 +24,7 @@
24 "Mirostat_Tau_desc": "Целевая перплексия.",24 "Mirostat_Tau_desc": "Целевая перплексия.",
25 "Mirostat_Eta_desc": "Скорость обучения Mirostat.",25 "Mirostat_Eta_desc": "Скорость обучения Mirostat.",
26 "Temperature Last": "Температура последней",26 "Temperature Last": "Температура последней",
27 "LLaMA / Mistral / Yi models only": "Только для моделей LLaMA / Mistral / Yi. Перед этим обязательно выберите подходящий токенизатор.\nПоследовательности, которых не должно быть на выходе.\nОдна на строку. Текст или [идентификаторы токенов].\nМногие токены имеют пробел впереди. Используйте счетчик токенов, если не уверены, что правильно их определяете.",27 "LLaMA / Mistral / Yi models only": "Только для моделей LLaMA / Mistral / Yi. Перед этим обязательно выберите подходящий токенизатор.\nПоследовательности, которых не должно быть на выходе.\nОдна на строку. Текст или [идентификаторы токенов].\nМногие токены имеют пробел впереди. Используйте счётчик токенов, если не уверены, что правильно их определяете.",
28 "Example: some text [42, 69, 1337]": "Пример:\nкакой-то текст\n[42, 69, 1337]",28 "Example: some text [42, 69, 1337]": "Пример:\nкакой-то текст\n[42, 69, 1337]",
29 "Classifier Free Guidance. More helpful tip coming soon": "Classifier Free Guidance. Чуть позже опишем более подробно",29 "Classifier Free Guidance. More helpful tip coming soon": "Classifier Free Guidance. Чуть позже опишем более подробно",
30 "Scale": "Scale",30 "Scale": "Scale",
@@ -91,7 +91,7 @@
91 "Impersonation prompt": "Промпт для перевоплощения",91 "Impersonation prompt": "Промпт для перевоплощения",
92 "Prompt that is used for Impersonation function": "Промпт, применяемый при генерации действий от лица пользователя",92 "Prompt that is used for Impersonation function": "Промпт, применяемый при генерации действий от лица пользователя",
93 "Logit Bias": "Смещение логитов",93 "Logit Bias": "Смещение логитов",
94 "Helps to ban or reenforce the usage of certain words": "Запрещает или поощряет использование определенных слов",94 "Helps to ban or reenforce the usage of certain words": "Запрещает или поощряет использование определенных слов.",
95 "View / Edit bias preset": "Просмотр / Редактирование пресета смещения",95 "View / Edit bias preset": "Просмотр / Редактирование пресета смещения",
96 "Add bias entry": "Добавить правило смещения",96 "Add bias entry": "Добавить правило смещения",
97 "Connect": "Подключиться",97 "Connect": "Подключиться",
@@ -201,8 +201,8 @@
201 "Bubbles": "Пузыри",201 "Bubbles": "Пузыри",
202 "No Blur Effect": "Отключить размытие",202 "No Blur Effect": "Отключить размытие",
203 "No Text Shadows": "Отключить тень текста",203 "No Text Shadows": "Отключить тень текста",
204 "Waifu Mode": "Режим вайфу",204 "Waifu Mode": "Режим визуальной новеллы",
205 "Message Timer": "Таймер сообщений",205 "Message Timer": "Длительность генерации сообщений",
206 "Model Icon": "Значки моделей",206 "Model Icon": "Значки моделей",
207 "Advanced Character Search": "Расширенный поиск по персонажам",207 "Advanced Character Search": "Расширенный поиск по персонажам",
208 "Allow {{char}}: in bot messages": "Показывать {{char}}: в ответах",208 "Allow {{char}}: in bot messages": "Показывать {{char}}: в ответах",
@@ -211,12 +211,12 @@
211 "Lorebook Import Dialog": "Показывать окно импорта лорбука",211 "Lorebook Import Dialog": "Показывать окно импорта лорбука",
212 "MUI Preset": "Пресет MUI:",212 "MUI Preset": "Пресет MUI:",
213 "If set in the advanced character definitions, this field will be displayed in the characters list.": "Если это поле задано в расширенных параметрах персонажа, оно будет отображаться в списке персонажей.",213 "If set in the advanced character definitions, this field will be displayed in the characters list.": "Если это поле задано в расширенных параметрах персонажа, оно будет отображаться в списке персонажей.",
214 "Relaxed API URLS": "Смягчённые адреса API",214 "Relaxed API URLS": "\"Ленивые\" адреса API",
215 "Custom CSS": "Пользовательский CSS",215 "Custom CSS": "Пользовательский CSS",
216 "Mancer Model": "Модель Mancer",216 "Mancer Model": "Модель Mancer",
217 "API Type": "Тип API",217 "API Type": "Тип API",
218 "Aphrodite API key": "Ключ от API Aphrodite",218 "Aphrodite API key": "Ключ от API Aphrodite",
219 "Relax message trim in Groups": "Мягкая обрезка сообщений в группах",219 "Relax message trim in Groups": "Смягчить требования к сообщениям ИИ в группах",
220 "Characters Hotswap": "HotSwap (смена персонажей на лету)",220 "Characters Hotswap": "HotSwap (смена персонажей на лету)",
221 "Request token probabilities": "Запрашивать вероятность токена",221 "Request token probabilities": "Запрашивать вероятность токена",
222 "Movable UI Panels": "Подвижные панели UI",222 "Movable UI Panels": "Подвижные панели UI",
@@ -229,10 +229,10 @@
229 "Text Shadow Width": "Размер теней текста",229 "Text Shadow Width": "Размер теней текста",
230 "Swipes": "Свайпы",230 "Swipes": "Свайпы",
231 "Miscellaneous": "Разное",231 "Miscellaneous": "Разное",
232 "Background Sound Only": "Только фоновый звук",232 "Background Sound Only": "Только фоновые звуки",
233 "Auto-load Last Chat": "Автозагрузка последнего чата",233 "Auto-load Last Chat": "Автозагрузка последнего чата",
234 "Auto-save Message Edits": "Автоматически сохранять отредактированные сообщения",234 "Auto-save Message Edits": "Автоматически сохранять отредактированные сообщения",
235 "Auto-fix Markdown": "Автоисправление разметки",235 "Auto-fix Markdown": "Автоисправление разметки Markdown",
236 "Auto-scroll Chat": "Автоматическая прокрутка чата",236 "Auto-scroll Chat": "Автоматическая прокрутка чата",
237 "Send on Enter": "Отправка на Enter",237 "Send on Enter": "Отправка на Enter",
238 "Debug Menu": "Меню отладки",238 "Debug Menu": "Меню отладки",
@@ -329,7 +329,7 @@
329 "Global Lore First": "Сначала глобальный лор",329 "Global Lore First": "Сначала глобальный лор",
330 "Recursive Scan": "Рекурсивное сканирование",330 "Recursive Scan": "Рекурсивное сканирование",
331 "Case Sensitive": "Учитывать регистр",331 "Case Sensitive": "Учитывать регистр",
332 "Alert On Overflow": "Оповещение о переполнении",332 "Alert On Overflow": "Оповещение о превышении бюджета",
333 "Use Probability": "Использовать вероятность",333 "Use Probability": "Использовать вероятность",
334 "Exclude from recursion": "Исключить из рекурсии",334 "Exclude from recursion": "Исключить из рекурсии",
335 "Entry Title/Memo": "Название или заметка о записи",335 "Entry Title/Memo": "Название или заметка о записи",
@@ -354,7 +354,7 @@
354 "Chat Background": "Фон чата",354 "Chat Background": "Фон чата",
355 "UI Background": "Фон UI",355 "UI Background": "Фон UI",
356 "Mad Lab Mode": "Режим безумца",356 "Mad Lab Mode": "Режим безумца",
357 "Show Message Token Count": "Счетчик токенов сообщения",357 "Show Message Token Count": "Показывать кол-во токенов в сообщении",
358 "Compact Input Area (Mobile)": "Компактная зона ввода",358 "Compact Input Area (Mobile)": "Компактная зона ввода",
359 "Zen Sliders": "Дзен слайдеры",359 "Zen Sliders": "Дзен слайдеры",
360 "UI Border": "Границы UI",360 "UI Border": "Границы UI",
@@ -363,7 +363,7 @@
363 "Tags as Folders": "Теги как папки",363 "Tags as Folders": "Теги как папки",
364 "Streaming FPS": "FPS для стриминга",364 "Streaming FPS": "FPS для стриминга",
365 "Gestures": "Жесты",365 "Gestures": "Жесты",
366 "Message IDs": "ID сообщений",366 "Message IDs": "Номера сообщений",
367 "Prefer Character Card Prompt": "Приоритет промпту из карточки персонажа",367 "Prefer Character Card Prompt": "Приоритет промпту из карточки персонажа",
368 "Prefer Character Card Jailbreak": "Приоритет джейлбрейку из карточки персонажа",368 "Prefer Character Card Jailbreak": "Приоритет джейлбрейку из карточки персонажа",
369 "Press Send to continue": "Кнопка отправки продолжает сообщение",369 "Press Send to continue": "Кнопка отправки продолжает сообщение",
@@ -372,7 +372,7 @@
372 "Never resize avatars": "Не менять размер аватарок",372 "Never resize avatars": "Не менять размер аватарок",
373 "Show avatar filenames": "Показывать названия файлов аватарок",373 "Show avatar filenames": "Показывать названия файлов аватарок",
374 "Import Card Tags": "Импортировать теги карточки",374 "Import Card Tags": "Импортировать теги карточки",
375 "Confirm message deletion": "Подтверждение удаления сообщений",375 "Confirm message deletion": "Спрашивать при удалении сообщений",
376 "Spoiler Free Mode": "Режим без спойлеров",376 "Spoiler Free Mode": "Режим без спойлеров",
377 "Auto-swipe": "Автоматические свайпы",377 "Auto-swipe": "Автоматические свайпы",
378 "Minimum generated message length": "Минимальная длина сгенерированных сообщений",378 "Minimum generated message length": "Минимальная длина сгенерированных сообщений",
@@ -392,11 +392,11 @@
392 "Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список действий с сообщением, а не прятать их за '...'.",392 "Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список действий с сообщением, а не прятать их за '...'.",
393 "Alternative UI for numeric sampling parameters with fewer steps": "Уменьшить кол-во шагов для параметров, регулируемых слайдерами.",393 "Alternative UI for numeric sampling parameters with fewer steps": "Уменьшить кол-во шагов для параметров, регулируемых слайдерами.",
394 "Entirely unrestrict all numeric sampling parameters": "Снять ограничения со всех числовых сэмплеров.",394 "Entirely unrestrict all numeric sampling parameters": "Снять ограничения со всех числовых сэмплеров.",
395 "Time the AI's message generation, and show the duration in the chat log": "Время генерации сообщений ИИ и его показ в журнале чата.",395 "Time the AI's message generation, and show the duration in the chat log": "Засекать время, за которое было сгенерировано сообщение, и отображать эту информацию в чате.",
396 "Show a timestamp for each message in the chat log": "Показывать временную метку для каждого сообщения в журнале чата.",396 "Show a timestamp for each message in the chat log": "Показывать временную метку для каждого сообщения в журнале чата.",
397 "Show an icon for the API that generated the message": "Показать значок API, сгенерировавшего сообщение.",397 "Show an icon for the API that generated the message": "Показывать значок API, сгенерировавшего сообщение.",
398 "Show sequential message numbers in the chat log": "Показывать порядковые номера сообщений в журнале чата.",398 "Show sequential message numbers in the chat log": "Показывать порядковые номера сообщений в чате.",
399 "Show the number of tokens in each message in the chat log": "Показать количество токенов в каждом сообщении в журнале чата.",399 "Show the number of tokens in each message in the chat log": "Показывать количество токенов в каждом сообщении в чате.",
400 "Single-row message input area. Mobile only, no effect on PC": "Однорядная область ввода сообщений. Только для мобильных устройств, на ПК не работает.",400 "Single-row message input area. Mobile only, no effect on PC": "Однорядная область ввода сообщений. Только для мобильных устройств, на ПК не работает.",
401 "In the Character Management panel, show quick selection buttons for favorited characters": "На панели управления персонажами будут отображены кнопки быстрого выбора для избранных персонажей.",401 "In the Character Management panel, show quick selection buttons for favorited characters": "На панели управления персонажами будут отображены кнопки быстрого выбора для избранных персонажей.",
402 "Show tagged character folders in the character list": "Отобразить теговые папки с персонажами в списке персонажей.",402 "Show tagged character folders in the character list": "Отобразить теговые папки с персонажами в списке персонажей.",
@@ -404,7 +404,7 @@
404 "Only play a sound when ST's browser tab is unfocused": "Воспроизводить звук только тогда, когда вкладка браузера ST не выбрана.",404 "Only play a sound when ST's browser tab is unfocused": "Воспроизводить звук только тогда, когда вкладка браузера ST не выбрана.",
405 "Reduce the formatting requirements on API URLs": "Снижение требований к форматированию URL-адресов API.",405 "Reduce the formatting requirements on API URLs": "Снижение требований к форматированию URL-адресов API.",
406 "Ask to import the World Info/Lorebook for every new character with embedded lorebook. If unchecked, a brief message will be shown instead": "Спрашивать разрешение на импорт лорбука для всех персонажей со встроенным лорбуком. При выключенной опции вместо этого будет показываться короткое сообщение",406 "Ask to import the World Info/Lorebook for every new character with embedded lorebook. If unchecked, a brief message will be shown instead": "Спрашивать разрешение на импорт лорбука для всех персонажей со встроенным лорбуком. При выключенной опции вместо этого будет показываться короткое сообщение",
407 "Restore unsaved user input on page refresh": "Восстановление несохраненного пользовательского запроса при обновлении страницы.",407 "Restore unsaved user input on page refresh": "Восстановление несохраненного пользовательского сообщения при обновлении страницы.",
408 "Allow repositioning certain UI elements by dragging them. PC only, no effect on mobile": "Позволяет перемещать некоторые элементы интерфейса путем их перетаскивания. Только для ПК, на телефонах не работает.",408 "Allow repositioning certain UI elements by dragging them. PC only, no effect on mobile": "Позволяет перемещать некоторые элементы интерфейса путем их перетаскивания. Только для ПК, на телефонах не работает.",
409 "MovingUI preset. Predefined/saved draggable positions": "Пресет для MovingUI. Предопределенные/сохраненные позиции для перетаскивания.",409 "MovingUI preset. Predefined/saved draggable positions": "Пресет для MovingUI. Предопределенные/сохраненные позиции для перетаскивания.",
410 "Save movingUI changes to a new file": "Сохранение изменений MovingUI в новый файл.",410 "Save movingUI changes to a new file": "Сохранение изменений MovingUI в новый файл.",
@@ -419,7 +419,7 @@
419 "Show arrow buttons on the last in-chat message to generate alternative AI responses. Both PC and mobile": "Показывать кнопки со стрелками на последнем сообщении в чате, чтобы генерировать альтернативные ответы ИИ. Как для ПК, так и для мобильных устройств.",419 "Show arrow buttons on the last in-chat message to generate alternative AI responses. Both PC and mobile": "Показывать кнопки со стрелками на последнем сообщении в чате, чтобы генерировать альтернативные ответы ИИ. Как для ПК, так и для мобильных устройств.",
420 "Allow using swiping gestures on the last in-chat message to trigger swipe generation. Mobile only, no effect on PC": "Позволяет использовать жесты смахивания на последнем сообщении в чате, чтобы вызвать альтернативную генерацию. Только для мобильных устройств, на ПК не работает.",420 "Allow using swiping gestures on the last in-chat message to trigger swipe generation. Mobile only, no effect on PC": "Позволяет использовать жесты смахивания на последнем сообщении в чате, чтобы вызвать альтернативную генерацию. Только для мобильных устройств, на ПК не работает.",
421 "Save edits to messages without confirmation as you type": "Сохранять правки в сообщениях без подтверждения по мере ввода текста.",421 "Save edits to messages without confirmation as you type": "Сохранять правки в сообщениях без подтверждения по мере ввода текста.",
422 "Skip encoding and characters in message text, allowing a subset of HTML markup as well as Markdown": "Не кодировать символы < и > в тексте сообщения, что позволяет использовать подмножество HTML-разметки, а также Markdown.",422 "Skip encoding and characters in message text, allowing a subset of HTML markup as well as Markdown": "Не кодировать символы < и > в тексте сообщения, что позволяет использовать часть возможностей HTML-разметки, а также разметку Markdown.",
423 "Allow AI messages in groups to contain lines spoken by other group members": "Разрешить ИИ в группах генерировать строчки за других участников группы в своих сообщениях.",423 "Allow AI messages in groups to contain lines spoken by other group members": "Разрешить ИИ в группах генерировать строчки за других участников группы в своих сообщениях.",
424 "Requests logprobs from the API for the Token Probabilities feature": "Запросить логпробы из API для функции Token Probabilities.",424 "Requests logprobs from the API for the Token Probabilities feature": "Запросить логпробы из API для функции Token Probabilities.",
425 "Automatically reject and re-generate AI message based on configurable criteria": "Автоматическое отклонение и повторная генерация сообщений AI на основе настраиваемых критериев.",425 "Automatically reject and re-generate AI message based on configurable criteria": "Автоматическое отклонение и повторная генерация сообщений AI на основе настраиваемых критериев.",
@@ -549,7 +549,7 @@
549 "Character Management": "Управление персонажами",549 "Character Management": "Управление персонажами",
550 "Locked = Character Management panel will stay open": "Закреплено = Панель управление персонажами останется открытой",550 "Locked = Character Management panel will stay open": "Закреплено = Панель управление персонажами останется открытой",
551 "Select/Create Characters": "Выбрать/Создать персонажа",551 "Select/Create Characters": "Выбрать/Создать персонажа",
552 "Token counts may be inaccurate and provided just for reference.": "Счетчик токенов может быть неточным, используйте как ориентир",552 "Token counts may be inaccurate and provided just for reference.": "Счётчик токенов может быть неточным, используйте как ориентир",
553 "Click to select a new avatar for this character": "Нажмите чтобы выбрать новый аватар для этого персонажа",553 "Click to select a new avatar for this character": "Нажмите чтобы выбрать новый аватар для этого персонажа",
554 "Example: [{{user}} is a 28-year-old Romanian cat girl.]": "Пример:\n [{{user}} is a 28-year-old Romanian cat girl.]",554 "Example: [{{user}} is a 28-year-old Romanian cat girl.]": "Пример:\n [{{user}} is a 28-year-old Romanian cat girl.]",
555 "Toggle grid view": "Сменить вид сетки",555 "Toggle grid view": "Сменить вид сетки",
@@ -718,7 +718,7 @@
718 "Google Model": "Модель Google",718 "Google Model": "Модель Google",
719 "Cohere API Key": "Ключ от API Cohere",719 "Cohere API Key": "Ключ от API Cohere",
720 "Cohere Model": "Модель Cohere",720 "Cohere Model": "Модель Cohere",
721 "Model Order": "Сортировка моделей OpenRouter",721 "OpenRouter Model Sorting": "Сортировка моделей OpenRouter",
722 "Alphabetically": "По алфавиту",722 "Alphabetically": "По алфавиту",
723 "Price": "По цене (наиболее низкая)",723 "Price": "По цене (наиболее низкая)",
724 "Context Size": "По размеру контекста",724 "Context Size": "По размеру контекста",
@@ -1024,10 +1024,10 @@
1024 "image_inlining_hint_2": ". Также это можно сделать через меню",1024 "image_inlining_hint_2": ". Также это можно сделать через меню",
1025 "image_inlining_hint_3": ".",1025 "image_inlining_hint_3": ".",
1026 "Contest Winners": "Победители конкурса",1026 "Contest Winners": "Победители конкурса",
1027 "Rename background": "Переименовать фон",1027 "Rename Background": "Переименовать фон",
1028 "Lock": "Закрепить",1028 "Lock": "Закрепить",
1029 "Unlock": "Открепить",1029 "Unlock": "Открепить",
1030 "Delete background": "Удалить фон",1030 "Delete Background": "Удалить фон",
1031 "Export all": "Экспортировать всё",1031 "Export all": "Экспортировать всё",
1032 "Export all your prompts to a file": "Экспортировать все промпты в виде файла",1032 "Export all your prompts to a file": "Экспортировать все промпты в виде файла",
1033 "Don't add character names.": "Не добавлять имя персонажа",1033 "Don't add character names.": "Не добавлять имя персонажа",
@@ -1621,7 +1621,7 @@
1621 "Auxiliary": "Вспомогательный",1621 "Auxiliary": "Вспомогательный",
1622 "Post-History Instructions": "Инструкции после истории",1622 "Post-History Instructions": "Инструкции после истории",
1623 "Current persona updated": "Текущая персона изменена",1623 "Current persona updated": "Текущая персона изменена",
1624 "Your messages will now be sent as ${0}": "Ваши сообщения будут отправляться от лица ${0}",1624 "Your messages will now be sent as ${0}": "Теперь вы будете подписываться как ${0}",
1625 "Copied!": "Скопировано!",1625 "Copied!": "Скопировано!",
1626 "Are you sure you want to delete this message?": "Вы точно хотите удалить это сообщение?",1626 "Are you sure you want to delete this message?": "Вы точно хотите удалить это сообщение?",
1627 "Delete Message": "Удалить сообщение",1627 "Delete Message": "Удалить сообщение",
@@ -1977,7 +1977,7 @@
1977 "Importing Tags": "Импорт тегов",1977 "Importing Tags": "Импорт тегов",
1978 "Couldn't import tags:": "Не удалось импортировать теги:",1978 "Couldn't import tags:": "Не удалось импортировать теги:",
1979 "Allow fallback models": "Разрешить резервные модели",1979 "Allow fallback models": "Разрешить резервные модели",
1980 "Allow fallback providers": "Разрешить fallback-провайдеров",1980 "Allow fallback providers": "Разрешить резервных провайдеров",
1981 "To use instruct formatting, switch to OpenRouter under Text Completion API.": "Переключитесь на OpenRouter в Text Completion API, чтобы использовать форматирование Instruct-режима.",1981 "To use instruct formatting, switch to OpenRouter under Text Completion API.": "Переключитесь на OpenRouter в Text Completion API, чтобы использовать форматирование Instruct-режима.",
1982 "Select providers. No selection = all providers.": "Выберите провайдера. Нет выбранного = выбраны все.",1982 "Select providers. No selection = all providers.": "Выберите провайдера. Нет выбранного = выбраны все.",
1983 "Model Providers": "Провайдеры моделей",1983 "Model Providers": "Провайдеры моделей",
@@ -2406,7 +2406,7 @@
2406 "${0} is a system assistant. Choose another character.": "Персонаж ${0} выбран в качестве системного помощника. Выберите другого.",2406 "${0} is a system assistant. Choose another character.": "Персонаж ${0} выбран в качестве системного помощника. Выберите другого.",
2407 "Set ${0} as your assistant.": "Персонаж ${0} установлен в качестве помощника.",2407 "Set ${0} as your assistant.": "Персонаж ${0} установлен в качестве помощника.",
2408 "${0} is no longer your assistant.": "Персонаж ${0} снят с роли помощника.",2408 "${0} is no longer your assistant.": "Персонаж ${0} снят с роли помощника.",
2409 "Manage API keys": "Управление ключами",2409 "Manage API keys": "Менеджер ключей",
2410 "Key:": "Ключ:",2410 "Key:": "Ключ:",
2411 "No secrets saved.": "Ключей нет.",2411 "No secrets saved.": "Ключей нет.",
2412 "Add Secret": "Добавить ключ",2412 "Add Secret": "Добавить ключ",
@@ -2433,7 +2433,7 @@
2433 "prompt_post_processing_semi_tools": "Semi-strict (чередовать роли, функции включены)",2433 "prompt_post_processing_semi_tools": "Semi-strict (чередовать роли, функции включены)",
2434 "prompt_post_processing_strict_tools": "Strict (чередовать роли, сначала пользователь; функции включены)",2434 "prompt_post_processing_strict_tools": "Strict (чередовать роли, сначала пользователь; функции включены)",
2435 "prompt_post_processing_single": "Только одно сообщение пользователя (функции отключены)",2435 "prompt_post_processing_single": "Только одно сообщение пользователя (функции отключены)",
2436 "prompt_post_processing_merge_tools": "Объединять идущие подряд сообщение с одной ролью (функции включены)",2436 "prompt_post_processing_merge_tools": "Объединять идущие подряд сообщения с одной ролью (функции включены)",
2437 "completions note prefix": "Постфикс ",2437 "completions note prefix": "Постфикс ",
2438 "suffix will be added automatically.": "будет добавлен автоматически.",2438 "suffix will be added automatically.": "будет добавлен автоматически.",
2439 "Tabby Model": "Модель Tabby",2439 "Tabby Model": "Модель Tabby",
@@ -2548,7 +2548,7 @@
2548 "Default (top of context)": "По умолчанию (наверху контекста)",2548 "Default (top of context)": "По умолчанию (наверху контекста)",
2549 "Bind Model to Templates": "Связать модель с шаблоном",2549 "Bind Model to Templates": "Связать модель с шаблоном",
2550 "Persona Auto Selected": "Персона автоматически изменена",2550 "Persona Auto Selected": "Персона автоматически изменена",
2551 "Auto-selected persona based on ${0} connection.<br />Your messages will now be sent as ${1}.": "Персона выбрана автоматически на основе следующего критерия: ${0}<br />Ваши сообщения будут отправляться от лица ${1}",2551 "Auto-selected persona based on ${0} connection.<br />Your messages will now be sent as ${1}.": "Персона выбрана автоматически на основе следующего критерия: ${0}<br />Теперь вы будете подписываться как ${1}",
2552 "Filter to specific generation types.": "Применять промпт только при определённых типах генерации.",2552 "Filter to specific generation types.": "Применять промпт только при определённых типах генерации.",
2553 "Quiet": "Тихая",2553 "Quiet": "Тихая",
2554 "Swipe": "Свайп",2554 "Swipe": "Свайп",
@@ -2601,7 +2601,7 @@
2601 "sd_snap_txt": "Корректировать автоматически выбранное разрешение",2601 "sd_snap_txt": "Корректировать автоматически выбранное разрешение",
2602 "sd_snap": "Подгонять картинки с фиксированным соотношением сторон (фоны, портреты) к ближайшему известному разрешению (рекомендуется для SDXL)",2602 "sd_snap": "Подгонять картинки с фиксированным соотношением сторон (фоны, портреты) к ближайшему известному разрешению (рекомендуется для SDXL)",
2603 "Source": "API",2603 "Source": "API",
2604 "Hint: Save an API key in AI Horde API settings to use it here.": "Подсказка: сохраните ключ от API в настройках API на сайте AI Horde, чтобы использовать его автоматически.",2604 "Hint: Save an API key in AI Horde API settings to use it here.": "Совет: сохраните ключ от API в настройках API AI Horde, чтобы автоматически подтянуть его сюда.",
2605 "Sanitize prompts (recommended)": "Прогонять промпты через санитайзер (рекомендуется)",2605 "Sanitize prompts (recommended)": "Прогонять промпты через санитайзер (рекомендуется)",
2606 "Sampling method": "Метод сэмплинга",2606 "Sampling method": "Метод сэмплинга",
2607 "Resolution": "Целевое разрешение",2607 "Resolution": "Целевое разрешение",
@@ -2652,5 +2652,97 @@
2652 "sd_auto_auth_warning_1": " запускайте Stable Diffusion с флагом",2652 "sd_auto_auth_warning_1": " запускайте Stable Diffusion с флагом",
2653 "sd_auto_auth_warning_2": "! Адрес SD должен быть доступен с сервера SillyTavern.",2653 "sd_auto_auth_warning_2": "! Адрес SD должен быть доступен с сервера SillyTavern.",
2654 "Upscale by": "Множитель апскейлинга",2654 "Upscale by": "Множитель апскейлинга",
2655 "Hires steps (2nd pass)": "Кол-во шагов Hires (на втором проходе)"2655 "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": "Доступные голоса"
2656}2748}
public/locales/uk-ua.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "консолі розробника Anthropic",369 "Anthropic's developer console": "консолі розробника Anthropic",
370 "Claude Model": "Модель Claude",370 "Claude Model": "Модель Claude",
371 "Window AI Model": "Модель Window AI",371 "Window AI Model": "Модель Window AI",
372 "Model Order": "Сортування моделі OpenRouter",372 "OpenRouter Model Sorting": "Сортування моделі OpenRouter",
373 "Alphabetically": "За алфавітом",373 "Alphabetically": "За алфавітом",
374 "Price": "Ціна (найдешевша)",374 "Price": "Ціна (найдешевша)",
375 "Context Size": "Розмір контексту",375 "Context Size": "Розмір контексту",
public/locales/vi-vn.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "developer console của Anthropic",369 "Anthropic's developer console": "developer console của Anthropic",
370 "Claude Model": "Model Claude",370 "Claude Model": "Model Claude",
371 "Window AI Model": "Model Window AI",371 "Window AI Model": "Model Window AI",
372 "Model Order": "Sắp xếp model OpenRouter",372 "OpenRouter Model Sorting": "Sắp xếp model OpenRouter",
373 "Alphabetically": "Theo thứ tự bảng chữ cái",373 "Alphabetically": "Theo thứ tự bảng chữ cái",
374 "Price": "Giá (rẻ nhất)",374 "Price": "Giá (rẻ nhất)",
375 "Context Size": "Kích thước bối cảnh",375 "Context Size": "Kích thước bối cảnh",
public/locales/zh-cn.json+19 -7
@@ -452,7 +452,7 @@
452 "Claude Model": "Claude 模型",452 "Claude Model": "Claude 模型",
453 "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。",453 "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。",
454 "Allow fallback models": "允许后备模型",454 "Allow fallback models": "允许后备模型",
455 "Model Order": "OpenRouter 模型顺序",455 "OpenRouter Model Sorting": "OpenRouter 模型顺序",
456 "Alphabetically": "按字母顺序",456 "Alphabetically": "按字母顺序",
457 "Price": "价格(最便宜)",457 "Price": "价格(最便宜)",
458 "Context Size": "上下文长度",458 "Context Size": "上下文长度",
@@ -1479,7 +1479,7 @@
1479 "Image Captioning": "图像描述",1479 "Image Captioning": "图像描述",
1480 "Source": "来源",1480 "Source": "来源",
1481 "Local": "本地",1481 "Local": "本地",
1482 "Multimodal (OpenAI / Anthropic / llama / Google)": "多模态(OpenAI / Anthropic / llama / Google)",1482 "Multimodal": "多模态",
1483 "Extras": "更多",1483 "Extras": "更多",
1484 "Horde": "Horde",1484 "Horde": "Horde",
1485 "API": "API",1485 "API": "API",
@@ -1646,8 +1646,9 @@
1646 "macro for manual injection)": "宏用于手动注入)",1646 "macro for manual injection)": "宏用于手动注入)",
1647 "Color": "颜色",1647 "Color": "颜色",
1648 "Only apply color as accent": "仅应用颜色作为强调",1648 "Only apply color as accent": "仅应用颜色作为强调",
1649 "ext_regex_new_global_script_desc": "新增「全局」正规表达式",1649 "ext_regex_new_global_script_desc": "新增「全局」正则表达式",
1650 "ext_regex_new_scoped_script_desc": "新增「局部」正规表达式",1650 "ext_regex_new_scoped_script_desc": "新增「局部」正则表达式",
1651 "ext_regex_new_preset_script_desc": "新增「预设」正则表达式",
1651 "ext_regex_debugger_active_rules": "激活的规则",1652 "ext_regex_debugger_active_rules": "激活的规则",
1652 "ext_regex_debugger_save_order": "保存此顺序",1653 "ext_regex_debugger_save_order": "保存此顺序",
1653 "ext_regex_debugger_testing_area": "测试区域",1654 "ext_regex_debugger_testing_area": "测试区域",
@@ -1668,6 +1669,7 @@
1668 "ext_regex_preset_delete": "删除当前预设",1669 "ext_regex_preset_delete": "删除当前预设",
1669 "ext_regex_new_global_script": "新建全局正则",1670 "ext_regex_new_global_script": "新建全局正则",
1670 "ext_regex_new_scoped_script": "新建局部正则",1671 "ext_regex_new_scoped_script": "新建局部正则",
1672 "ext_regex_new_preset_script": "新建预设正则",
1671 "ext_regex_import_script": "导入正则",1673 "ext_regex_import_script": "导入正则",
1672 "ext_regex_bulk_edit": "批量编辑",1674 "ext_regex_bulk_edit": "批量编辑",
1673 "ext_regex_debugger_desc": "高级正则调试工具",1675 "ext_regex_debugger_desc": "高级正则调试工具",
@@ -1677,9 +1679,13 @@
1677 "ext_regex_global_scripts_desc": "影响所有角色,保存在本地设定中",1679 "ext_regex_global_scripts_desc": "影响所有角色,保存在本地设定中",
1678 "No scripts found": "没有找到脚本",1680 "No scripts found": "没有找到脚本",
1679 "ext_regex_scoped_scripts": "局部正则脚本",1681 "ext_regex_scoped_scripts": "局部正则脚本",
1682 "ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中",
1683 "ext_regex_preset_scripts": "预设正则脚本",
1684 "ext_regex_preset_scripts_desc": "只影响当前预设,保存在预设中",
1680 "ext_regex_disallow_scoped": "不允许使用局部正则",1685 "ext_regex_disallow_scoped": "不允许使用局部正则",
1681 "ext_regex_allow_scoped": "允许使用局部正则",1686 "ext_regex_allow_scoped": "允许使用局部正则",
1682 "ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中",1687 "ext_regex_disallow_preset": "不允许使用预设正则",
1688 "ext_regex_allow_preset": "允许使用预设正则",
1683 "Regex Editor": "正则表达式编辑器",1689 "Regex Editor": "正则表达式编辑器",
1684 "Test Mode": "测试模式",1690 "Test Mode": "测试模式",
1685 "ext_regex_desc": "“正则”是一个使用“正则表达式”来查找/替换字符串的工具。如果您想了解更多信息,请点击标题旁边的“?”。",1691 "ext_regex_desc": "“正则”是一个使用“正则表达式”来查找/替换字符串的工具。如果您想了解更多信息,请点击标题旁边的“?”。",
@@ -1728,10 +1734,16 @@
1728 "ext_regex_disable_script": "禁用脚本",1734 "ext_regex_disable_script": "禁用脚本",
1729 "ext_regex_enable_script": "启用脚本",1735 "ext_regex_enable_script": "启用脚本",
1730 "ext_regex_edit_script": "编辑脚本",1736 "ext_regex_edit_script": "编辑脚本",
1731 "ext_regex_move_to_global": "移至全局脚本",1737 "ext_regex_move_to_global": "移至全局",
1732 "ext_regex_move_to_scoped": "移至作用域脚本",1738 "ext_regex_move_to_scoped": "移至局部",
1739 "ext_regex_move_to_preset": "移至预设",
1733 "ext_regex_export_script": "导出脚本",1740 "ext_regex_export_script": "导出脚本",
1734 "ext_regex_delete_script": "删除脚本",1741 "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)启用。",
1735 "Trigger Stable Diffusion": "触发Stable Diffusion",1747 "Trigger Stable Diffusion": "触发Stable Diffusion",
1736 "Abort current image generation task": "中止当前图像生成",1748 "Abort current image generation task": "中止当前图像生成",
1737 "Stop Image Generation": "停止图像生成",1749 "Stop Image Generation": "停止图像生成",
public/locales/zh-tw.json+2 -2
@@ -368,7 +368,7 @@
368 "Anthropic's developer console": "Anthropic 的開發者控制台",368 "Anthropic's developer console": "Anthropic 的開發者控制台",
369 "Claude Model": "Claude 模型",369 "Claude Model": "Claude 模型",
370 "Window AI Model": "Window AI 模型",370 "Window AI Model": "Window AI 模型",
371 "Model Order": "模型順序",371 "OpenRouter Model Sorting": "模型順序",
372 "Alphabetically": "按字母順序",372 "Alphabetically": "按字母順序",
373 "Price": "價格(最便宜的)",373 "Price": "價格(最便宜的)",
374 "Context Size": "上下文長度",374 "Context Size": "上下文長度",
@@ -1674,7 +1674,7 @@
1674 "Message Template": "訊息範本",1674 "Message Template": "訊息範本",
1675 "Model ID": "模型 ID",1675 "Model ID": "模型 ID",
1676 "mui_reset": "重設",1676 "mui_reset": "重設",
1677 "Multimodal (OpenAI / Anthropic / llama / Google)": "多模態(OpenAI/Anthropic/llama/Google)",1677 "Multimodal": "多模態",
1678 "must be set in Tabby's config.yml to switch models.": "須在 Tabby's config.yml 中設定以切換模型。",1678 "must be set in Tabby's config.yml to switch models.": "須在 Tabby's config.yml 中設定以切換模型。",
1679 "Names as Stop Strings": "將名稱用作停止字串",1679 "Names as Stop Strings": "將名稱用作停止字串",
1680 "Never": "從不",1680 "Never": "從不",
public/manifest.json+10 -0
@@ -25,6 +25,16 @@
25 "src": "img/apple-icon-144x144.png", 25 "src": "img/apple-icon-144x144.png",
26 "sizes": "144x144", 26 "sizes": "144x144",
27 "type": "image/png" 27 "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"
28 } 38 }
29 ] 39 ]
30} 40}
public/script.js+372 -260
@@ -43,10 +43,11 @@ import {
43 importEmbeddedWorldInfo,43 importEmbeddedWorldInfo,
44 checkEmbeddedWorld,44 checkEmbeddedWorld,
45 setWorldInfoButtonClass,45 setWorldInfoButtonClass,
46 importWorldInfo,
47 wi_anchor_position,46 wi_anchor_position,
48 world_info_include_names,47 world_info_include_names,
49 initWorldInfo,48 initWorldInfo,
49 charUpdatePrimaryWorld,
50 charSetAuxWorlds,
50} from './scripts/world-info.js';51} from './scripts/world-info.js';
5152
52import {53import {
@@ -175,6 +176,9 @@ import {
175 localizePagination,176 localizePagination,
176 renderPaginationDropdown,177 renderPaginationDropdown,
177 paginationDropdownChangeHandler,178 paginationDropdownChangeHandler,
179 importFromExternalUrl,
180 shiftUpByOne,
181 shiftDownByOne,
178} from './scripts/utils.js';182} from './scripts/utils.js';
179import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids } from './scripts/constants.js';183import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids } from './scripts/constants.js';
180184
@@ -267,6 +271,8 @@ import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, ini
267import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';271import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
268import { event_types, eventSource } from './scripts/events.js';272import { event_types, eventSource } from './scripts/events.js';
269import { initAccessibility } from './scripts/a11y.js';273import { initAccessibility } from './scripts/a11y.js';
274import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
275import { initDomHandlers } from './scripts/dom-handlers.js';
270276
271// API OBJECT FOR EXTERNAL WIRING277// API OBJECT FOR EXTERNAL WIRING
272globalThis.SillyTavern = {278globalThis.SillyTavern = {
@@ -390,7 +396,7 @@ let isExportPopupOpen = false;
390396
391// Saved here for performance reasons397// Saved here for performance reasons
392const messageTemplate = $('#message_template .mes');398const messageTemplate = $('#message_template .mes');
393const chatElement = $('#chat');399export const chatElement = $('#chat');
394400
395let dialogueResolve = null;401let dialogueResolve = null;
396let dialogueCloseStop = false;402let dialogueCloseStop = false;
@@ -403,6 +409,7 @@ let fav_ch_checked = false;
403let scrollLock = false;409let scrollLock = false;
404export let abortStatusCheck = new AbortController();410export let abortStatusCheck = new AbortController();
405export let charDragDropHandler = null;411export let charDragDropHandler = null;
412export let chatDragDropHandler = null;
406413
407/** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */414/** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
408export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed;415export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed;
@@ -637,6 +644,7 @@ async function firstLoadInit() {
637644
638 showLoader();645 showLoader();
639 registerPromptManagerMigration();646 registerPromptManagerMigration();
647 initDomHandlers();
640 initStandaloneMode();648 initStandaloneMode();
641 initLibraryShims();649 initLibraryShims();
642 addShowdownPatch(showdown);650 addShowdownPatch(showdown);
@@ -1344,7 +1352,7 @@ export async function replaceCurrentChat() {
1344}1352}
13451353
1346export async function showMoreMessages(messagesToLoad = null) {1354export async function showMoreMessages(messagesToLoad = null) {
1347 const firstDisplayedMesId = $('#chat').children('.mes').first().attr('mesid');1355 const firstDisplayedMesId = chatElement.children('.mes').first().attr('mesid');
1348 let messageId = Number(firstDisplayedMesId);1356 let messageId = Number(firstDisplayedMesId);
1349 let count = messagesToLoad || power_user.chat_truncation || Number.MAX_SAFE_INTEGER;1357 let count = messagesToLoad || power_user.chat_truncation || Number.MAX_SAFE_INTEGER;
13501358
@@ -1355,7 +1363,7 @@ export async function showMoreMessages(messagesToLoad = null) {
1355 }1363 }
13561364
1357 console.debug('Inserting messages before', messageId, 'count', count, 'chat length', chat.length);1365 console.debug('Inserting messages before', messageId, 'count', count, 'chat length', chat.length);
1358 const prevHeight = $('#chat').prop('scrollHeight');1366 const prevHeight = chatElement.prop('scrollHeight');
1359 const isButtonInView = isElementInViewport($('#show_more_messages')[0]);1367 const isButtonInView = isElementInViewport($('#show_more_messages')[0]);
13601368
1361 while (messageId > 0 && count > 0) {1369 while (messageId > 0 && count > 0) {
@@ -1370,8 +1378,8 @@ export async function showMoreMessages(messagesToLoad = null) {
1370 }1378 }
13711379
1372 if (isButtonInView) {1380 if (isButtonInView) {
1373 const newHeight = $('#chat').prop('scrollHeight');1381 const newHeight = chatElement.prop('scrollHeight');
1374 $('#chat').scrollTop(newHeight - prevHeight);1382 chatElement.scrollTop(newHeight - prevHeight);
1375 }1383 }
13761384
1377 applyStylePins();1385 applyStylePins();
@@ -1384,7 +1392,7 @@ export async function printMessages() {
13841392
1385 if (chat.length > count) {1393 if (chat.length > count) {
1386 startIndex = chat.length - count;1394 startIndex = chat.length - count;
1387 $('#chat').append('<div id="show_more_messages">Show more messages</div>');1395 chatElement.append('<div id="show_more_messages">Show more messages</div>');
1388 }1396 }
13891397
1390 for (let i = startIndex; i < chat.length; i++) {1398 for (let i = startIndex; i < chat.length; i++) {
@@ -1407,8 +1415,8 @@ export async function printMessages() {
1407 }1415 }
1408 }1416 }
14091417
1410 $('#chat .mes').removeClass('last_mes');1418 chatElement.find('.mes').removeClass('last_mes');
1411 $('#chat .mes').last().addClass('last_mes');1419 chatElement.find('.mes').last().addClass('last_mes');
1412 hideSwipeButtons();1420 hideSwipeButtons();
1413 showSwipeButtons();1421 showSwipeButtons();
1414 scrollChatToBottom();1422 scrollChatToBottom();
@@ -1441,7 +1449,7 @@ export async function clearChat() {
1441 if (is_delete_mode) {1449 if (is_delete_mode) {
1442 $('#dialogue_del_mes_cancel').trigger('click');1450 $('#dialogue_del_mes_cancel').trigger('click');
1443 }1451 }
1444 $('#chat').children().remove();1452 chatElement.children().remove();
1445 if ($('.zoomed_avatar[forChar]').length) {1453 if ($('.zoomed_avatar[forChar]').length) {
1446 console.debug('saw avatars to remove');1454 console.debug('saw avatars to remove');
1447 $('.zoomed_avatar[forChar]').remove();1455 $('.zoomed_avatar[forChar]').remove();
@@ -1453,7 +1461,7 @@ export async function clearChat() {
14531461
1454export async function deleteLastMessage() {1462export async function deleteLastMessage() {
1455 chat.length = chat.length - 1;1463 chat.length = chat.length - 1;
1456 $('#chat').children('.mes').last().remove();1464 chatElement.children('.mes').last().remove();
1457 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);1465 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
1458}1466}
14591467
@@ -1796,7 +1804,7 @@ function getMessageFromTemplate({
1796 * @param {boolean} [options.rerenderMessage=true] Whether to re-render the message content (inside <c>.mes_text</c>)1804 * @param {boolean} [options.rerenderMessage=true] Whether to re-render the message content (inside <c>.mes_text</c>)
1797 */1805 */
1798export function updateMessageBlock(messageId, message, { rerenderMessage = true } = {}) {1806export function updateMessageBlock(messageId, message, { rerenderMessage = true } = {}) {
1799 const messageElement = $(`#chat [mesid="${messageId}"]`);1807 const messageElement = chatElement.find(`[mesid="${messageId}"]`);
1800 if (rerenderMessage) {1808 if (rerenderMessage) {
1801 const text = message?.extra?.display_text ?? message.mes;1809 const text = message?.extra?.display_text ?? message.mes;
1802 messageElement.find('.mes_text').html(messageFormatting(text, message.name, message.is_system, message.is_user, messageId, {}, false));1810 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) {
1818 // Add image to message1826 // Add image to message
1819 if (mes.extra?.image) {1827 if (mes.extra?.image) {
1820 const container = messageElement.find('.mes_img_container');1828 const container = messageElement.find('.mes_img_container');
1821 const chatHeight = $('#chat').prop('scrollHeight');1829 const chatHeight = chatElement.prop('scrollHeight');
1822 const image = messageElement.find('.mes_img');1830 const image = messageElement.find('.mes_img');
1823 const text = messageElement.find('.mes_text');1831 const text = messageElement.find('.mes_text');
1824 const isInline = !!mes.extra?.inline_image;1832 const isInline = !!mes.extra?.inline_image;
@@ -1826,10 +1834,10 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
1826 if (!adjustScroll) {1834 if (!adjustScroll) {
1827 return;1835 return;
1828 }1836 }
1829 const scrollPosition = $('#chat').scrollTop();1837 const scrollPosition = chatElement.scrollTop();
1830 const newChatHeight = $('#chat').prop('scrollHeight');1838 const newChatHeight = chatElement.prop('scrollHeight');
1831 const diff = newChatHeight - chatHeight;1839 const diff = newChatHeight - chatHeight;
1832 $('#chat').scrollTop(scrollPosition + diff);1840 chatElement.scrollTop(scrollPosition + diff);
1833 };1841 };
1834 image.off('load').on('load', function () {1842 image.off('load').on('load', function () {
1835 image.removeAttr('alt');1843 image.removeAttr('alt');
@@ -1876,16 +1884,16 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
1876 const container = $('#message_video_template .mes_video_container').clone();1884 const container = $('#message_video_template .mes_video_container').clone();
1877 messageElement.find('.mes_video_container').remove();1885 messageElement.find('.mes_video_container').remove();
1878 messageElement.find('.mes_block').append(container);1886 messageElement.find('.mes_block').append(container);
1879 const chatHeight = $('#chat').prop('scrollHeight');1887 const chatHeight = chatElement.prop('scrollHeight');
1880 const video = container.find('.mes_video');1888 const video = container.find('.mes_video');
1881 video.off('loadedmetadata').on('loadedmetadata', function () {1889 video.off('loadedmetadata').on('loadedmetadata', function () {
1882 if (!adjustScroll) {1890 if (!adjustScroll) {
1883 return;1891 return;
1884 }1892 }
1885 const scrollPosition = $('#chat').scrollTop();1893 const scrollPosition = chatElement.scrollTop();
1886 const newChatHeight = $('#chat').prop('scrollHeight');1894 const newChatHeight = chatElement.prop('scrollHeight');
1887 const diff = newChatHeight - chatHeight;1895 const diff = newChatHeight - chatHeight;
1888 $('#chat').scrollTop(scrollPosition + diff);1896 chatElement.scrollTop(scrollPosition + diff);
1889 });1897 });
18901898
1891 video.attr('src', mes.extra?.video);1899 video.attr('src', mes.extra?.video);
@@ -1927,7 +1935,7 @@ export function addCopyToCodeBlocks(messageElement) {
1927 e.stopPropagation();1935 e.stopPropagation();
1928 });1936 });
1929 copyButton.addEventListener('pointerup', async function () {1937 copyButton.addEventListener('pointerup', async function () {
1930 const text = codeBlocks.get(i).innerText;1938 const text = codeBlocks.get(i).textContent;
1931 await copyText(text);1939 await copyText(text);
1932 toastr.info(t`Copied!`, '', { timeOut: 2000 });1940 toastr.info(t`Copied!`, '', { timeOut: 2000 });
1933 });1941 });
@@ -2040,7 +2048,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
2040 // Callers push the new message to chat before calling addOneMessage2048 // Callers push the new message to chat before calling addOneMessage
2041 const newMessageId = typeof forceId == 'number' ? forceId : chat.length - 1;2049 const newMessageId = typeof forceId == 'number' ? forceId : chat.length - 1;
20422050
2043 const newMessage = $(`#chat [mesid="${newMessageId}"]`);2051 const newMessage = chatElement.find(`[mesid="${newMessageId}"]`);
2044 const isSmallSys = mes?.extra?.isSmallSys;2052 const isSmallSys = mes?.extra?.isSmallSys;
20452053
2046 if (isSmallSys === true) {2054 if (isSmallSys === true) {
@@ -2102,8 +2110,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
2102 }2110 }
21032111
2104 if (showSwipes) {2112 if (showSwipes) {
2105 $('#chat .mes').last().addClass('last_mes');2113 chatElement.find('.mes').last().addClass('last_mes');
2106 $('#chat .mes').eq(-2).removeClass('last_mes');2114 chatElement.find('.mes').eq(-2).removeClass('last_mes');
2107 hideSwipeButtons();2115 hideSwipeButtons();
2108 showSwipeButtons();2116 showSwipeButtons();
2109 }2117 }
@@ -2252,6 +2260,33 @@ export function substituteParams(content, _name1, _name2, _original, _group, _re
2252 }2260 }
2253 };2261 };
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
2255 if (_replaceCharacterCard) {2290 if (_replaceCharacterCard) {
2256 const fields = getCharacterCardFields();2291 const fields = getCharacterCardFields();
2257 environment.charPrompt = fields.system || '';2292 environment.charPrompt = fields.system || '';
@@ -2281,6 +2316,7 @@ export function substituteParams(content, _name1, _name2, _original, _group, _re
2281 environment.char = _name2 ?? name2;2316 environment.char = _name2 ?? name2;
2282 environment.group = environment.charIfNotGroup = getGroupValue(true);2317 environment.group = environment.charIfNotGroup = getGroupValue(true);
2283 environment.groupNotMuted = getGroupValue(false);2318 environment.groupNotMuted = getGroupValue(false);
2319 environment.notChar = getNotCharValue();
2284 environment.model = getGeneratingModel();2320 environment.model = getGeneratingModel();
22852321
2286 if (additionalMacro && typeof additionalMacro === 'object') {2322 if (additionalMacro && typeof additionalMacro === 'object') {
@@ -2905,8 +2941,12 @@ class StreamingProcessor {
2905 false,2941 false,
2906 );2942 );
2907 if (this.messageTextDom instanceof HTMLElement) {2943 if (this.messageTextDom instanceof HTMLElement) {
2944 if (power_user.stream_fade_in) {
2945 applyStreamFadeIn(this.messageTextDom, formattedText);
2946 } else {
2908 this.messageTextDom.innerHTML = formattedText;2947 this.messageTextDom.innerHTML = formattedText;
2909 }2948 }
2949 }
29102950
2911 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration(), this.timeToFirstToken);2951 const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration(), this.timeToFirstToken);
2912 if (this.messageTimerDom instanceof HTMLElement) {2952 if (this.messageTimerDom instanceof HTMLElement) {
@@ -2925,7 +2965,7 @@ class StreamingProcessor {
2925 async onFinishStreaming(messageId, text) {2965 async onFinishStreaming(messageId, text) {
2926 this.markUIGenStopped();2966 this.markUIGenStopped();
2927 await this.onProgressStreaming(messageId, text, true);2967 await this.onProgressStreaming(messageId, text, true);
2928 addCopyToCodeBlocks($(`#chat .mes[mesid="${messageId}"]`));2968 addCopyToCodeBlocks(chatElement.find(`.mes[mesid="${messageId}"]`));
29292969
2930 await this.reasoningHandler.finish(messageId);2970 await this.reasoningHandler.finish(messageId);
29312971
@@ -3164,6 +3204,11 @@ export async function generateRaw({ prompt = '', api = null, instructOverride =
3164 // construct final prompt from the input. Can either be a string or an array of chat-style messages.3204 // construct final prompt from the input. Can either be a string or an array of chat-style messages.
3165 prompt = createRawPrompt(prompt, api, instructOverride, quietToLoud, systemPrompt, prefill);3205 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
3167 try {3212 try {
3168 if (responseLengthCustomized) {3213 if (responseLengthCustomized) {
3169 TempResponseLength.save(api, responseLength);3214 TempResponseLength.save(api, responseLength);
@@ -3171,6 +3216,23 @@ export async function generateRaw({ prompt = '', api = null, instructOverride =
3171 /** @type {object|any[]} */3216 /** @type {object|any[]} */
3172 let generateData = {};3217 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
3174 switch (api) {3236 switch (api) {
3175 case 'kobold':3237 case 'kobold':
3176 case 'koboldhorde':3238 case 'koboldhorde':
@@ -3250,6 +3312,7 @@ export async function generateRaw({ prompt = '', api = null, instructOverride =
32503312
3251 return message;3313 return message;
3252 } finally {3314 } finally {
3315 eventSource.removeListener(event_types.GENERATION_STOPPED, abortHook);
3253 if (responseLengthCustomized && TempResponseLength.isCustomized()) {3316 if (responseLengthCustomized && TempResponseLength.isCustomized()) {
3254 TempResponseLength.restore(api);3317 TempResponseLength.restore(api);
3255 TempResponseLength.removeEventHook(api, eventHook);3318 TempResponseLength.removeEventHook(api, eventHook);
@@ -3353,7 +3416,7 @@ class TempResponseLength {
3353 */3416 */
3354function removeLastMessage() {3417function removeLastMessage() {
3355 return new Promise((resolve) => {3418 return new Promise((resolve) => {
3356 const lastMes = $('#chat').children('.mes').last();3419 const lastMes = chatElement.children('.mes').last();
3357 if (lastMes.length === 0) {3420 if (lastMes.length === 0) {
3358 return resolve();3421 return resolve();
3359 }3422 }
@@ -3575,18 +3638,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3575 creatorNotes,3638 creatorNotes,
3576 } = getCharacterCardFields();3639 } = 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
3590 // Depth prompt (character-specific A/N)3641 // Depth prompt (character-specific A/N)
3591 removeDepthPrompts();3642 removeDepthPrompts();
3592 const groupDepthPrompts = getGroupDepthPrompts(selected_group, Number(this_chid));3643 const groupDepthPrompts = getGroupDepthPrompts(selected_group, Number(this_chid));
@@ -3717,12 +3768,8 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
37173768
3718 let mesExamplesArray = parseMesExamples(mesExamples, isInstruct);3769 let mesExamplesArray = parseMesExamples(mesExamples, isInstruct);
37193770
3720 //////////////////////////////////
3721 // Extension added strings
3722 // Set non-WI AN3771 // Set non-WI AN
3723 setFloatingPrompt();3772 setFloatingPrompt();
3724 // Add persona description to prompt
3725 addPersonaDescriptionExtensionPrompt();
37263773
3727 // Add WI to prompt (and also inject WI to AN value via hijack)3774 // Add WI to prompt (and also inject WI to AN value via hijack)
3728 // Make quiet prompt available for WIAN3775 // Make quiet prompt available for WIAN
@@ -3738,7 +3785,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3738 creatorNotes: creatorNotes,3785 creatorNotes: creatorNotes,
3739 trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',3786 trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
3740 };3787 };
3741 const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);3788 const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth, outletEntries } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
3742 setExtensionPrompt(inject_ids.QUIET_PROMPT, '', extension_prompt_types.IN_PROMPT, 0, true);3789 setExtensionPrompt(inject_ids.QUIET_PROMPT, '', extension_prompt_types.IN_PROMPT, 0, true);
37433790
3744 // Add message example WI3791 // Add message example WI
@@ -3770,17 +3817,38 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3770 if (skipWIAN !== true) {3817 if (skipWIAN !== true) {
3771 console.log('skipWIAN not active, adding WIAN');3818 console.log('skipWIAN not active, adding WIAN');
3772 // Add all depth WI entries to prompt3819 // Add all depth WI entries to prompt
3773 flushWIDepthInjections();3820 flushWIInjections();
3774 if (Array.isArray(worldInfoDepth)) {3821 if (Array.isArray(worldInfoDepth)) {
3775 worldInfoDepth.forEach((e) => {3822 worldInfoDepth.forEach((e) => {
3776 const joinedEntries = e.entries.join('\n');3823 const joinedEntries = e.entries.join('\n');
3777 setExtensionPrompt(inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, extension_prompt_types.IN_CHAT, e.depth, false, e.role);3824 setExtensionPrompt(inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, extension_prompt_types.IN_CHAT, e.depth, false, e.role);
3778 });3825 });
3779 }3826 }
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 }
3780 } else {3832 } else {
3781 console.log('skipping WIAN');3833 console.log('skipping WIAN');
3782 }3834 }
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
3784 // Collect before / after story string injections3852 // Collect before / after story string injections
3785 const beforeScenarioAnchor = await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT);3853 const beforeScenarioAnchor = await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT);
3786 const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT);3854 const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT);
@@ -3845,14 +3913,14 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3845 // This operation will result in the injectedIndices indexes being off by one3913 // This operation will result in the injectedIndices indexes being off by one
3846 coreChat.push({ mes: jailbreak, is_user: true });3914 coreChat.push({ mes: jailbreak, is_user: true });
3847 // Add +1 to the elements to correct for the new PHI/Jailbreak message.3915 // 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);
3849 }3917 }
3850 }3918 }
3851 }3919 }
38523920
3853 let chat2 = [];3921 let chat2 = [];
3854 let continue_mag = '';3922 let continue_mag = '';
3855 const userMessageIndices = [];3923 let userMessageIndices = [];
3856 const lastUserMessageIndex = coreChat.findLastIndex(x => x.is_user);3924 const lastUserMessageIndex = coreChat.findLastIndex(x => x.is_user);
38573925
3858 for (let i = coreChat.length - 1, j = 0; i >= 0; i--, j++) {3926 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
3951 // Only add the chat in context if past the greeting message4019 // Only add the chat in context if past the greeting message
3952 if (isContinue && (chat2.length > 1 || main_api === 'openai')) {4020 if (isContinue && (chat2.length > 1 || main_api === 'openai')) {
3953 cyclePrompt = chat2.shift();4021 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);
3954 }4025 }
39554026
3956 // Collect enough messages to fill the context4027 // Collect enough messages to fill the context
3957 let arrMes = new Array(chat2.length);4028 let arrMes = new Array(chat2.length);
3958 let tokenCount = await getMessagesTokenCount();4029 let tokenCount = await getMessagesTokenCount();
3959 let lastAddedIndex = -1;4030 let lastAddedIndex = 0;
39604031
3961 // Pre-allocate all injections first.4032 // Pre-allocate all injections first.
3962 // If it doesn't fit - user shot himself in the foot4033 // If it doesn't fit - user shot himself in the foot
3963 for (const index of injectedIndices) {4034 for (const index of injectedIndices) {
4035 // not needed for OAI prompting
4036 if (main_api == 'openai') {
4037 break;
4038 }
4039
3964 const item = chat2[index];4040 const item = chat2[index];
39654041
3966 if (typeof item !== 'string') {4042 if (typeof item !== 'string') {
@@ -4394,7 +4470,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
4394 }4470 }
4395 }4471 }
43964472
4397 await eventSource.emit(event_types.GENERATE_AFTER_DATA, generate_data);4473 await eventSource.emit(event_types.GENERATE_AFTER_DATA, generate_data, dryRun);
43984474
4399 if (dryRun) {4475 if (dryRun) {
4400 return Promise.resolve();4476 return Promise.resolve();
@@ -4703,7 +4779,7 @@ export function stopGeneration() {
4703 * @returns {Promise<number[]>} Array of indices where the extension prompts were injected4779 * @returns {Promise<number[]>} Array of indices where the extension prompts were injected
4704 */4780 */
4705async function doChatInject(messages, isContinue) {4781async function doChatInject(messages, isContinue) {
4706 const injectedIndices = [];4782 const injectedMessages = [];
4707 let totalInsertedMessages = 0;4783 let totalInsertedMessages = 0;
4708 messages.reverse();4784 messages.reverse();
47094785
@@ -4743,18 +4819,21 @@ async function doChatInject(messages, isContinue) {
4743 const injectIdx = Math.min(depth + totalInsertedMessages, messages.length);4819 const injectIdx = Math.min(depth + totalInsertedMessages, messages.length);
4744 messages.splice(injectIdx, 0, ...roleMessages);4820 messages.splice(injectIdx, 0, ...roleMessages);
4745 totalInsertedMessages += roleMessages.length;4821 totalInsertedMessages += roleMessages.length;
4746 injectedIndices.push(...Array.from({ length: roleMessages.length }, (_, i) => injectIdx + i));4822 injectedMessages.push(...roleMessages);
4747 }4823 }
4748 }4824 }
47494825
4826 const injectedIndices = injectedMessages.map(msg => messages.indexOf(msg));
4750 messages.reverse();4827 messages.reverse();
4751 return injectedIndices;4828 return injectedIndices;
4752}4829}
47534830
4754function flushWIDepthInjections() {4831function flushWIInjections() {
4755 //prevent custom depth WI entries (which have unique random key names) from duplicating4832 const depthPrefix = inject_ids.CUSTOM_WI_DEPTH;
4833 const outletPrefix = inject_ids.CUSTOM_WI_OUTLET('');
4834
4756 for (const key of Object.keys(extension_prompts)) {4835 for (const key of Object.keys(extension_prompts)) {
4757 if (key.startsWith(inject_ids.CUSTOM_WI_DEPTH)) {4836 if (key.startsWith(depthPrefix) || key.startsWith(outletPrefix)) {
4758 delete extension_prompts[key];4837 delete extension_prompts[key];
4759 }4838 }
4760 }4839 }
@@ -4775,7 +4854,7 @@ function unblockGeneration(type) {
4775 showSwipeButtons();4854 showSwipeButtons();
4776 setGenerationProgress(0);4855 setGenerationProgress(0);
4777 flushEphemeralStoppingStrings();4856 flushEphemeralStoppingStrings();
4778 flushWIDepthInjections();4857 flushWIInjections();
4779}4858}
47804859
4781export function getNextMessageId(type) {4860export function getNextMessageId(type) {
@@ -4977,6 +5056,8 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul
4977 await populateFileAttachment(message);5056 await populateFileAttachment(message);
4978 statMesProcess(message, 'user', characters, this_chid, '');5057 statMesProcess(message, 'user', characters, this_chid, '');
49795058
5059 chat_metadata['tainted'] = true;
5060
4980 if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) {5061 if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) {
4981 chat.splice(insertAt, 0, message);5062 chat.splice(insertAt, 0, message);
4982 await saveChatConditional();5063 await saveChatConditional();
@@ -5110,18 +5191,18 @@ export async function duplicateCharacter() {
5110}5191}
51115192
5112function setInContextMessages(msgInContextCount, type) {5193function setInContextMessages(msgInContextCount, type) {
5113 $('#chat .mes').removeClass('lastInContext');5194 chatElement.find('.mes').removeClass('lastInContext');
51145195
5115 if (type === 'swipe' || type === 'regenerate' || type === 'continue') {5196 if (type === 'swipe' || type === 'regenerate' || type === 'continue') {
5116 msgInContextCount++;5197 msgInContextCount++;
5117 }5198 }
51185199
5119 const lastMessageBlock = $('#chat .mes:not([is_system="true"])').eq(-msgInContextCount);5200 const lastMessageBlock = chatElement.find('.mes:not([is_system="true"])').eq(-msgInContextCount);
5120 lastMessageBlock.addClass('lastInContext');5201 lastMessageBlock.addClass('lastInContext');
51215202
5122 if (lastMessageBlock.length === 0) {5203 if (lastMessageBlock.length === 0) {
5123 const firstMessageId = getFirstDisplayedMessageId();5204 const firstMessageId = getFirstDisplayedMessageId();
5124 $(`#chat .mes[mesid="${firstMessageId}"`).addClass('lastInContext');5205 chatElement.find(`.mes[mesid="${firstMessageId}"`).addClass('lastInContext');
5125 }5206 }
51265207
5127 // Update last id to chat. No metadata save on purpose, gets hopefully saved via another call5208 // 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
5679 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);5760 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
5680 }5761 }
5681 const chat_id = (chat.length - 1);5762 const chat_id = (chat.length - 1);
5682 await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);5763 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
5683 addOneMessage(chat[chat_id], { type: 'swipe' });5764 addOneMessage(chat[chat_id], { type: 'swipe' });
5684 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);5765 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
5685 } else {5766 } else {
5686 chat[chat.length - 1]['mes'] = getMessage;5767 chat[chat.length - 1]['mes'] = getMessage;
5687 }5768 }
@@ -5703,9 +5784,9 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
5703 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);5784 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
5704 }5785 }
5705 const chat_id = (chat.length - 1);5786 const chat_id = (chat.length - 1);
5706 await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);5787 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
5707 addOneMessage(chat[chat_id], { type: 'swipe' });5788 addOneMessage(chat[chat_id], { type: 'swipe' });
5708 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);5789 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
5709 } else if (type === 'appendFinal') {5790 } else if (type === 'appendFinal') {
5710 oldMessage = chat[chat.length - 1]['mes'];5791 oldMessage = chat[chat.length - 1]['mes'];
5711 console.debug('Trying to appendFinal.');5792 console.debug('Trying to appendFinal.');
@@ -5724,9 +5805,9 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
5724 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);5805 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
5725 }5806 }
5726 const chat_id = (chat.length - 1);5807 const chat_id = (chat.length - 1);
5727 await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);5808 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
5728 addOneMessage(chat[chat_id], { type: 'swipe' });5809 addOneMessage(chat[chat_id], { type: 'swipe' });
5729 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);5810 !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
57305811
5731 } else {5812 } else {
5732 console.debug('entering chat update routine for non-swipe post');5813 console.debug('entering chat update routine for non-swipe post');
@@ -6617,7 +6698,7 @@ async function getChatResult() {
6617}6698}
66186699
6619function getFirstMessage() {6700function getFirstMessage() {
6620 const firstMes = characters[this_chid].first_mes || '';6701 const firstMes = characters[this_chid]?.first_mes || '';
6621 const alternateGreetings = characters[this_chid]?.data?.alternate_greetings;6702 const alternateGreetings = characters[this_chid]?.data?.alternate_greetings;
66226703
6623 const message = {6704 const message = {
@@ -6639,7 +6720,12 @@ function getFirstMessage() {
66396720
6640 message['swipe_id'] = 0;6721 message['swipe_id'] = 0;
6641 message['swipes'] = swipes;6722 message['swipes'] = swipes;
6642 message['swipe_info'] = [];6723 message['swipe_info'] = swipes.map(_ => ({
6724 send_date: message.send_date,
6725 gen_started: void 0,
6726 gen_finished: void 0,
6727 extra: {},
6728 }));
6643 }6729 }
66446730
6645 return message;6731 return message;
@@ -6752,7 +6838,7 @@ export function changeMainAPI() {
6752 $('#ai_module_block_novel').css('display', 'none');6838 $('#ai_module_block_novel').css('display', 'none');
6753 }6839 }
67546840
6755 $('#prompt_cost_block').toggle(selectedVal === 'textgenerationwebui');6841 $('#prompt_cost_block').toggle(selectedVal === 'textgenerationwebui' && textgen_settings.type === textgen_types.OPENROUTER);
67566842
6757 // Hide common settings for OpenAI6843 // Hide common settings for OpenAI
6758 console.debug('value?', selectedVal);6844 console.debug('value?', selectedVal);
@@ -7274,8 +7360,9 @@ export function getCurrentChatDetails() {
7274 * The function first fetches the chats, processes them, and then displays them in7360 * The function first fetches the chats, processes them, and then displays them in
7275 * the HTML. It also has a built-in search functionality that allows filtering the7361 * the HTML. It also has a built-in search functionality that allows filtering the
7276 * displayed chats based on a search query.7362 * displayed chats based on a search query.
7363 * @param {string[]} hightlightNames - An array of chat names to highlight
7277 */7364 */
7278export async function displayPastChats() {7365export async function displayPastChats(hightlightNames = []) {
7279 $('#select_chat_div').empty();7366 $('#select_chat_div').empty();
7280 $('#select_chat_search').val('').off('input');7367 $('#select_chat_search').val('').off('input');
72817368
@@ -7284,10 +7371,10 @@ export async function displayPastChats() {
7284 const displayName = chatDetails.characterName;7371 const displayName = chatDetails.characterName;
7285 const avatarImg = chatDetails.avatarImgURL;7372 const avatarImg = chatDetails.avatarImgURL;
72867373
7287 await displayChats('', currentChat, displayName, avatarImg, selected_group);7374 await displayChats('', currentChat, displayName, avatarImg, selected_group, hightlightNames);
72887375
7289 const debouncedDisplay = debounce((searchQuery) => {7376 const debouncedDisplay = debounce((searchQuery) => {
7290 displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group);7377 displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, []);
7291 });7378 });
72927379
7293 // Define the search input listener7380 // Define the search input listener
@@ -7303,7 +7390,7 @@ export async function displayPastChats() {
7303 }, 200);7390 }, 200);
7304}7391}
73057392
7306async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group) {7393async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
7307 try {7394 try {
7308 const trimExtension = (fileName) => String(fileName).replace('.jsonl', '');7395 const trimExtension = (fileName) => String(fileName).replace('.jsonl', '');
73097396
@@ -7343,6 +7430,12 @@ async function displayChats(searchQuery, currentChat, displayName, avatarImg, se
7343 }7430 }
73447431
7345 $('#select_chat_div').append(template);7432 $('#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 }
7346 }7439 }
7347 } catch (error) {7440 } catch (error) {
7348 console.error('Error loading chats:', error);7441 console.error('Error loading chats:', error);
@@ -7476,7 +7569,7 @@ export function select_rm_info(type, charId, previousCharId = null) {
74767569
7477/**7570/**
7478 * Selects the right menu for displaying the character editor.7571 * Selects the right menu for displaying the character editor.
7479 * @param {number|string} chid Character array index7572 * @param {string} chid Character array index
7480 * @param {object} [param1] Options for the switch7573 * @param {object} [param1] Options for the switch
7481 * @param {boolean} [param1.switchMenu=true] Whether to switch the menu7574 * @param {boolean} [param1.switchMenu=true] Whether to switch the menu
7482 */7575 */
@@ -7554,6 +7647,11 @@ export function select_selected_character(chid, { switchMenu = true } = {}) {
7554 $('#character_media_allowed_icon').toggle(externalMediaState);7647 $('#character_media_allowed_icon').toggle(externalMediaState);
7555 $('#character_media_forbidden_icon').toggle(!externalMediaState);7648 $('#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
7557 saveSettingsDebounced();7655 saveSettingsDebounced();
7558}7656}
75597657
@@ -7836,7 +7934,7 @@ export function showSwipeButtons() {
7836 };7934 };
7837 }7935 }
78387936
7839 const currentMessage = $('#chat').children().filter(`[mesid="${chat.length - 1}"]`);7937 const currentMessage = chatElement.children().filter(`[mesid="${chat.length - 1}"]`);
7840 const swipeId = chat[chat.length - 1].swipe_id;7938 const swipeId = chat[chat.length - 1].swipe_id;
7841 const swipeCounterText = formatSwipeCounter((swipeId + 1), chat[chat.length - 1].swipes.length);7939 const swipeCounterText = formatSwipeCounter((swipeId + 1), chat[chat.length - 1].swipes.length);
7842 const swipeRight = currentMessage.find('.swipe_right');7940 const swipeRight = currentMessage.find('.swipe_right');
@@ -7872,42 +7970,45 @@ export function hideSwipeButtons() {
7872/**7970/**
7873 * Deletes a swipe from the chat.7971 * Deletes a swipe from the chat.
7874 *7972 *
7875 * @param {number?} swipeId - The ID of the swipe to delete. If not provided, the current swipe will be deleted.7973 * @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.
7876 * @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.7975 * @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.
7877 */7976 */
7878export async function deleteSwipe(swipeId = null) {7977export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
7879 if (swipeId && (isNaN(swipeId) || swipeId < 0)) {7978 if (swipeId && (isNaN(swipeId) || swipeId < 0)) {
7880 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);7979 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
7881 return;7980 return;
7882 }7981 }
78837982
7884 const lastMessage = chat[chat.length - 1];7983 const message = chat[messageId];
7885 if (!lastMessage || !Array.isArray(lastMessage.swipes) || !lastMessage.swipes.length) {7984 if (!message || !Array.isArray(message.swipes) || !message.swipes.length) {
7886 toastr.warning(t`No messages to delete swipes from.`);7985 toastr.warning(t`No messages to delete swipes from.`);
7887 return;7986 return;
7888 }7987 }
78897988
7890 if (lastMessage.swipes.length <= 1) {7989 if (message.swipes.length <= 1) {
7891 toastr.warning(t`Can't delete the last swipe.`);7990 toastr.warning(t`Can't delete the last swipe.`);
7892 return;7991 return;
7893 }7992 }
78947993
7895 swipeId = swipeId ?? lastMessage.swipe_id;7994 swipeId = swipeId ?? message.swipe_id;
78967995
7897 if (swipeId < 0 || swipeId >= lastMessage.swipes.length) {7996 if (swipeId < 0 || swipeId >= message.swipes.length) {
7898 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);7997 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
7899 return;7998 return;
7900 }7999 }
79018000
7902 lastMessage.swipes.splice(swipeId, 1);8001 message.swipes.splice(swipeId, 1);
79038002
7904 if (Array.isArray(lastMessage.swipe_info) && lastMessage.swipe_info.length) {8003 if (Array.isArray(message.swipe_info) && message.swipe_info.length) {
7905 lastMessage.swipe_info.splice(swipeId, 1);8004 message.swipe_info.splice(swipeId, 1);
7906 }8005 }
79078006
7908 // Select the next swipe, or the one before if it was the last one8007 // Select the next swipe, or the one before if it was the last one
7909 const newSwipeId = Math.min(swipeId, lastMessage.swipes.length - 1);8008 const newSwipeId = Math.min(swipeId, message.swipes.length - 1);
7910 syncSwipeToMes(null, newSwipeId);8009 syncSwipeToMes(messageId, newSwipeId);
8010
8011 await eventSource.emit(event_types.MESSAGE_SWIPE_DELETED, { messageId, swipeId, newSwipeId });
79118012
7912 await saveChatConditional();8013 await saveChatConditional();
7913 await reloadCurrentChat();8014 await reloadCurrentChat();
@@ -7957,9 +8058,11 @@ export async function saveChatConditional() {
7957/**8058/**
7958 * Saves the chat to the server.8059 * Saves the chat to the server.
7959 * @param {FormData} formData Form data to send to the server.8060 * @param {FormData} formData Form data to send to the server.
7960 * @param {EventTarget} eventTarget Event target to trigger the event on.8061 * @param {object} [options={}] Options for the import
8062 * @param {boolean} [options.refresh] Whether to refresh the group chat list after import
8063 * @returns {Promise<string[]>} List of imported file names.
7961 */8064 */
7962async function importCharacterChat(formData, eventTarget) {8065async function importCharacterChat(formData, { refresh = true } = {}) {
7963 const fetchResult = await fetch('/api/chats/import', {8066 const fetchResult = await fetch('/api/chats/import', {
7964 method: 'POST',8067 method: 'POST',
7965 body: formData,8068 body: formData,
@@ -7969,26 +8072,25 @@ async function importCharacterChat(formData, eventTarget) {
79698072
7970 if (fetchResult.ok) {8073 if (fetchResult.ok) {
7971 const data = await fetchResult.json();8074 const data = await fetchResult.json();
7972 if (data.res) {8075 if (data.res && refresh) {
7973 await displayPastChats();8076 await displayPastChats();
7974 }8077 }
8078 return data?.fileNames || [];
7975 }8079 }
79768080
7977 if (eventTarget instanceof HTMLInputElement) {8081 return [];
7978 eventTarget.value = '';
7979 }
7980}8082}
79818083
7982function updateViewMessageIds(startFromZero = false) {8084function updateViewMessageIds(startFromZero = false) {
7983 const minId = startFromZero ? 0 : getFirstDisplayedMessageId();8085 const minId = startFromZero ? 0 : getFirstDisplayedMessageId();
79848086
7985 $('#chat').find('.mes').each(function (index, element) {8087 chatElement.find('.mes').each(function (index, element) {
7986 $(element).attr('mesid', minId + index);8088 $(element).attr('mesid', minId + index);
7987 $(element).find('.mesIDDisplay').text(`#${minId + index}`);8089 $(element).find('.mesIDDisplay').text(`#${minId + index}`);
7988 });8090 });
79898091
7990 $('#chat .mes').removeClass('last_mes');8092 chatElement.find('.mes').removeClass('last_mes');
7991 $('#chat .mes').last().addClass('last_mes');8093 chatElement.find('.mes').last().addClass('last_mes');
79928094
7993 updateEditArrowClasses();8095 updateEditArrowClasses();
7994}8096}
@@ -8000,14 +8102,14 @@ export function getFirstDisplayedMessageId() {
8000}8102}
80018103
8002function updateEditArrowClasses() {8104function updateEditArrowClasses() {
8003 $('#chat .mes .mes_edit_up').removeClass('disabled');8105 chatElement.find('.mes .mes_edit_up').removeClass('disabled');
8004 $('#chat .mes .mes_edit_down').removeClass('disabled');8106 chatElement.find('.mes .mes_edit_down').removeClass('disabled');
80058107
8006 if (this_edit_mes_id !== undefined) {8108 if (this_edit_mes_id !== undefined) {
8007 const down = $(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_down`);8109 const down = chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_down`);
8008 const up = $(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_up`);8110 const up = chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_up`);
8009 const lastId = Number($('#chat .mes').last().attr('mesid'));8111 const lastId = Number(chatElement.find('.mes').last().attr('mesid'));
8010 const firstId = Number($('#chat .mes').first().attr('mesid'));8112 const firstId = Number(chatElement.find('.mes').first().attr('mesid'));
80118113
8012 if (lastId == Number(this_edit_mes_id)) {8114 if (lastId == Number(this_edit_mes_id)) {
8013 down.addClass('disabled');8115 down.addClass('disabled');
@@ -8026,7 +8128,7 @@ function updateEditArrowClasses() {
8026export function closeMessageEditor(what = 'all') {8128export function closeMessageEditor(what = 'all') {
8027 if (what === 'message' || what === 'all') {8129 if (what === 'message' || what === 'all') {
8028 if (this_edit_mes_id) {8130 if (this_edit_mes_id) {
8029 $(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_cancel`).trigger('click');8131 chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_cancel`).trigger('click');
8030 }8132 }
8031 }8133 }
8032 if (what === 'reasoning' || what === 'all') {8134 if (what === 'reasoning' || what === 'all') {
@@ -8081,61 +8183,16 @@ async function openCharacterWorldPopup() {
8081 const selectedValue = $(this).val();8183 const selectedValue = $(this).val();
8082 const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN;8184 const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN;
8083 const name = !isNaN(worldIndex) ? world_names[worldIndex] : '';8185 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);
8112 }8187 }
81138188
8114 function handleExtrasWorldSelect() {8189 function handleExtrasWorldSelect(evt) {
8115 const selectedValues = $(this).val();8190 const el = evt?.currentTarget ?? this;
8116 const selectedWorlds = Array.isArray(selectedValues) ? selectedValues : [];8191 const selectedValues = $(el).val();
8117 let charLore = world_info.charLore ?? [];8192 const selected = Array.isArray(selectedValues) ? selectedValues : [];
8118 const tempExtraBooks = selectedWorlds.map((index) => world_names[index]).filter(Boolean);8193 const fileName = getCharaFilename(null, {});
8119 const existingCharIndex = charLore.findIndex((e) => e.name === fileName);8194 const nextList = selected.map(i => world_names[i]).filter(Boolean);
81208195 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();
8139 }8196 }
81408197
8141 // --- Populate Dropdowns ---8198 // --- Populate Dropdowns ---
@@ -8258,7 +8315,7 @@ function addAlternateGreeting(template, greeting, index, getArray, popup) {
8258 * Creates or edits a character based on the form data.8315 * Creates or edits a character based on the form data.
8259 * @param {Event} [e] Event that triggered the function call.8316 * @param {Event} [e] Event that triggered the function call.
8260 */8317 */
8261async function createOrEditCharacter(e) {8318export async function createOrEditCharacter(e) {
8262 $('#rm_info_avatar').html('');8319 $('#rm_info_avatar').html('');
8263 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));8320 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
8264 formData.set('fav', String(fav_ch_checked));8321 formData.set('fav', String(fav_ch_checked));
@@ -8701,10 +8758,10 @@ export function swipe_right(_event = null, { source, repeated } = {}) {
8701 easing: animation_easing,8758 easing: animation_easing,
8702 queue: false,8759 queue: false,
8703 complete: async function () {8760 complete: async function () {
8704 const is_animation_scroll = ($('#chat').scrollTop() >= ($('#chat').prop('scrollHeight') - $('#chat').outerHeight()) - 10);8761 const is_animation_scroll = (chatElement.scrollTop() >= (chatElement.prop('scrollHeight') - chatElement.outerHeight()) - 10);
8705 //console.log(parseInt(chat[chat.length-1]['swipe_id']));8762 //console.log(parseInt(chat[chat.length-1]['swipe_id']));
8706 //console.log(chat[chat.length-1]['swipes'].length);8763 //console.log(chat[chat.length-1]['swipes'].length);
8707 const swipeMessage = $('#chat').find('[mesid="' + (chat.length - 1) + '"]');8764 const swipeMessage = chatElement.find('[mesid="' + (chat.length - 1) + '"]');
8708 if (run_generate && parseInt(chat[chat.length - 1]['swipe_id']) === chat[chat.length - 1]['swipes'].length) {8765 if (run_generate && parseInt(chat[chat.length - 1]['swipe_id']) === chat[chat.length - 1]['swipes'].length) {
8709 //shows "..." while generating8766 //shows "..." while generating
8710 swipeMessage.find('.mes_text').html('...');8767 swipeMessage.find('.mes_text').html('...');
@@ -8737,12 +8794,12 @@ export function swipe_right(_event = null, { source, repeated } = {}) {
8737 queue: false,8794 queue: false,
8738 progress: function () {8795 progress: function () {
8739 // Scroll the chat down as the message expands8796 // Scroll the chat down as the message expands
8740 if (is_animation_scroll) $('#chat').scrollTop($('#chat')[0].scrollHeight);8797 if (is_animation_scroll) chatElement.scrollTop(chatElement[0].scrollHeight);
8741 },8798 },
8742 complete: function () {8799 complete: function () {
8743 this_mes_div.css('height', 'auto');8800 this_mes_div.css('height', 'auto');
8744 // Scroll the chat down to the bottom once the animation is complete8801 // Scroll the chat down to the bottom once the animation is complete
8745 if (is_animation_scroll) $('#chat').scrollTop($('#chat')[0].scrollHeight);8802 if (is_animation_scroll) chatElement.scrollTop(chatElement[0].scrollHeight);
8746 },8803 },
8747 });8804 });
8748 this_mes_div.children('.mes_block').transition({8805 this_mes_div.children('.mes_block').transition({
@@ -8888,6 +8945,8 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
8888 return;8945 return;
8889 }8946 }
88908947
8948 const exists = preserveFileName ? characters.find(character => character.avatar === preserveFileName) : undefined;
8949
8891 const format = ext[1].toLowerCase();8950 const format = ext[1].toLowerCase();
8892 $('#character_import_file_type').val(format);8951 $('#character_import_file_type').val(format);
8893 const formData = new FormData();8952 const formData = new FormData();
@@ -8914,10 +8973,20 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
8914 }8973 }
89158974
8916 if (data.file_name !== undefined) {8975 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
8917 $('#character_search_bar').val('').trigger('input');8983 $('#character_search_bar').val('').trigger('input');
89188984
8985 if (exists) {
8986 toastr.success(t`Character Replaced: ${String(data.file_name).replace('.png', '')}`);
8987 } else {
8919 toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`);8988 toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`);
8920 let avatarFileName = `${data.file_name}.png`;8989 }
8921 if (importTags) {8990 if (importTags) {
8922 await importCharactersTags([avatarFileName]);8991 await importCharactersTags([avatarFileName]);
8923 selectImportedChar(data.file_name);8992 selectImportedChar(data.file_name);
@@ -8972,7 +9041,7 @@ export async function doNewChat({ deleteCurrentChat = false } = {}) {
89729041
8973 if (selected_group) {9042 if (selected_group) {
8974 await createNewGroupChat(selected_group);9043 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
8976 }9045 }
8977 else {9046 else {
8978 //RossAscends: added character name to new chat filenames and replaced Date.now() with humanizedDateTime;9047 //RossAscends: added character name to new chat filenames and replaced Date.now() with humanizedDateTime;
@@ -9073,6 +9142,34 @@ export async function renameChat(oldFileName, newName) {
9073}9142}
90749143
9075/**9144/**
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 */
9149export 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/**
9076 * Forces the update of the chat name for a remote character.9173 * Forces the update of the chat name for a remote character.
9077 * @param {string|number} characterId Character ID to update chat name for9174 * @param {string|number} characterId Character ID to update chat name for
9078 * @param {string} newName New name for the chat9175 * @param {string} newName New name for the chat
@@ -9137,6 +9234,22 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
9137 characterKey = [characterKey];9234 characterKey = [characterKey];
9138 }9235 }
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
9140 for (const key of characterKey) {9253 for (const key of characterKey) {
9141 const character = characters.find(x => x.avatar == key);9254 const character = characters.find(x => x.avatar == key);
9142 if (!character) {9255 if (!character) {
@@ -9743,6 +9856,12 @@ jQuery(async function () {
9743 });9856 });
9744 });9857 });
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
9746 $('#favorite_button').on('click', function () {9865 $('#favorite_button').on('click', function () {
9747 updateFavButtonState(!fav_ch_checked);9866 updateFavButtonState(!fav_ch_checked);
9748 if (menu_type != 'create') {9867 if (menu_type != 'create') {
@@ -9940,24 +10059,7 @@ jQuery(async function () {
9940 }10059 }
994110060
9942 else if (id == 'option_close_chat') {10061 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 }
9961 }10063 }
996210064
9963 else if (id === 'option_settings') {10065 else if (id === 'option_settings') {
@@ -10021,15 +10123,15 @@ jQuery(async function () {
10021 });10123 });
1002210124
10023 if (this_del_mes >= 0) {10125 if (this_del_mes >= 0) {
10024 $(`.mes[mesid="${this_del_mes}"]`).nextAll('div').remove();10126 chatElement.find(`.mes[mesid="${this_del_mes}"]`).nextAll('div').remove();
10025 $(`.mes[mesid="${this_del_mes}"]`).remove();10127 chatElement.find(`.mes[mesid="${this_del_mes}"]`).remove();
10026 chat.length = this_del_mes;10128 chat.length = this_del_mes;
10027 chat_metadata['tainted'] = true;10129 chat_metadata['tainted'] = true;
10028 await saveChatConditional();10130 await saveChatConditional();
10029 chatElement.scrollTop(chatElement[0].scrollHeight);10131 chatElement.scrollTop(chatElement[0].scrollHeight);
10030 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);10132 await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
10031 $('#chat .mes').removeClass('last_mes');10133 chatElement.find('.mes').removeClass('last_mes');
10032 $('#chat .mes').last().addClass('last_mes');10134 chatElement.find('.mes').last().addClass('last_mes');
10033 } else {10135 } else {
10034 console.log('this_del_mes is not >= 0, not deleting');10136 console.log('this_del_mes is not >= 0, not deleting');
10035 }10137 }
@@ -10134,9 +10236,9 @@ jQuery(async function () {
10134 return;10236 return;
10135 }*/10237 }*/
1013610238
10137 let chatScrollPosition = $('#chat').scrollTop();10239 let chatScrollPosition = chatElement.scrollTop();
10138 if (this_edit_mes_id !== undefined) {10240 if (this_edit_mes_id !== undefined) {
10139 let mes_edited = $(`#chat [mesid="${this_edit_mes_id}"]`).find('.mes_edit_done');10241 let mes_edited = chatElement.find(`[mesid="${this_edit_mes_id}"]`).find('.mes_edit_done');
10140 if (Number(edit_mes_id) == chat.length - 1) { //if the generating swipe (...)10242 if (Number(edit_mes_id) == chat.length - 1) { //if the generating swipe (...)
10141 let run_edit = true;10243 let run_edit = true;
10142 if (chat[edit_mes_id]['swipe_id'] !== undefined) {10244 if (chat[edit_mes_id]['swipe_id'] !== undefined) {
@@ -10195,7 +10297,7 @@ jQuery(async function () {
10195 String(edit_textarea.val()).length,10297 String(edit_textarea.val()).length,
10196 );10298 );
10197 if (Number(this_edit_mes_id) === chat.length - 1) {10299 if (Number(this_edit_mes_id) === chat.length - 1) {
10198 $('#chat').scrollTop(chatScrollPosition);10300 chatElement.scrollTop(chatScrollPosition);
10199 }10301 }
1020010302
10201 updateEditArrowClasses();10303 updateEditArrowClasses();
@@ -10313,7 +10415,7 @@ jQuery(async function () {
1031310415
10314 hideSwipeButtons();10416 hideSwipeButtons();
10315 const targetId = Number(this_edit_mes_id) - 1;10417 const targetId = Number(this_edit_mes_id) - 1;
10316 const target = $(`#chat .mes[mesid="${targetId}"]`);10418 const target = chatElement.find(`.mes[mesid="${targetId}"]`);
10317 const root = $(this).closest('.mes');10419 const root = $(this).closest('.mes');
1031810420
10319 if (root.length === 0 || target.length === 0) {10421 if (root.length === 0 || target.length === 0) {
@@ -10342,7 +10444,7 @@ jQuery(async function () {
1034210444
10343 hideSwipeButtons();10445 hideSwipeButtons();
10344 const targetId = Number(this_edit_mes_id) + 1;10446 const targetId = Number(this_edit_mes_id) + 1;
10345 const target = $(`#chat .mes[mesid="${targetId}"]`);10447 const target = chatElement.find(`.mes[mesid="${targetId}"]`);
10346 const root = $(this).closest('.mes');10448 const root = $(this).closest('.mes');
1034710449
10348 if (root.length === 0 || target.length === 0) {10450 if (root.length === 0 || target.length === 0) {
@@ -10414,7 +10516,7 @@ jQuery(async function () {
10414 if (deleteOnlySwipe) {10516 if (deleteOnlySwipe) {
10415 const message = chat[this_edit_mes_id];10517 const message = chat[this_edit_mes_id];
10416 const swipe_id = message.swipe_id;10518 const swipe_id = message.swipe_id;
10417 await deleteSwipe(swipe_id);10519 await deleteSwipe(swipe_id, Number(this_edit_mes_id));
10418 return;10520 return;
10419 }10521 }
1042010522
@@ -10519,41 +10621,45 @@ jQuery(async function () {
10519 });10621 });
1052010622
10521 $('#chat_import_file').on('change', async function (e) {10623 $('#chat_import_file').on('change', async function (e) {
10522 const targetElement = /** @type {HTMLInputElement} */ (e.target);10624 const targetElement = e.target;
10523 if (!(targetElement instanceof HTMLInputElement)) {10625 const formElement = document.getElementById('form_import_chat');
10626 if (!(targetElement instanceof HTMLInputElement) || !(formElement instanceof HTMLFormElement)) {
10524 return;10627 return;
10525 }10628 }
10526 const file = targetElement.files[0];
1052710629
10528 if (!file) {10630 const importedFileNames = [];
10529 return;
10530 }
1053110631
10632 for (const file of targetElement.files) {
10532 const ext = file.name.match(/\.(\w+)$/);10633 const ext = file.name.match(/\.(\w+)$/);
10533 if (10634 const format = ext?.[1]?.toLowerCase();
10534 !ext ||10635
10535 (ext[1].toLowerCase() != 'json' && ext[1].toLowerCase() != 'jsonl')10636 if (!['json', 'jsonl'].includes(format)) {
10536 ) {10637 toastr.warning(t`Only JSON and JSONL files are supported for chat imports.`);
10537 return;10638 continue;
10538 }10639 }
1053910640
10540 if (selected_group && file.name.endsWith('.json')) {10641 if (selected_group && format === 'json') {
10541 toastr.warning('Only SillyTavern\'s own format is supported for group chat imports. Sorry!');10642 toastr.warning(t`Only SillyTavern's own format is supported for group chat imports. Sorry!`);
10542 return;10643 continue;
10543 }10644 }
1054410645
10545 const format = ext[1].toLowerCase();10646 const formData = new FormData(formElement);
10546 $('#chat_import_file_type').val(format);10647 formData.set('file_type', format);
10648 formData.set('avatar', file);
10649 formData.set('user_name', name1);
1054710650
10548 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_import_chat').get(0)));10651 const importFn = selected_group ? importGroupChat : importCharacterChat;
10549 formData.append('user_name', name1);10652 const result = await importFn(formData, { refresh: false });
10550 $('#select_chat_div').html('');10653 importedFileNames.push(...result);
10654 }
1055110655
10552 if (selected_group) {10656 if (importedFileNames.length > 0) {
10553 await importGroupChat(formData, e.originalEvent.target);10657 toastr.success(t`Successfully imported ${importedFileNames.length} chat(s).`);
10554 } else {
10555 await importCharacterChat(formData, e.originalEvent.target);
10556 }10658 }
10659
10660 await displayPastChats(importedFileNames);
10661
10662 targetElement.value = '';
10557 });10663 });
1055810664
10559 $('#rm_button_group_chats').on('click', function () {10665 $('#rm_button_group_chats').on('click', function () {
@@ -10775,7 +10881,7 @@ jQuery(async function () {
10775 return;10881 return;
10776 }10882 }
10777 if (isEditVisible && power_user.auto_save_msg_edits === true) {10883 if (isEditVisible && power_user.auto_save_msg_edits === true) {
10778 $(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_done`).trigger('click');10884 chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_done`).trigger('click');
10779 closeMessageEditor('reasoning');10885 closeMessageEditor('reasoning');
10780 $('#send_textarea').trigger('focus');10886 $('#send_textarea').trigger('focus');
10781 return;10887 return;
@@ -10819,27 +10925,66 @@ jQuery(async function () {
10819 }10925 }
10820 } break;10926 } break;
10821 case 'replace_update': {10927 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
10930 const POPUP_RESULT_URL = POPUP_RESULT.CUSTOM1, POPUP_RESULT_FILE = POPUP_RESULT.CUSTOM2;
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 });
10948
10949 // Remember the chat currently selected, so we can reload it after the replacement
10950 const currentChatFile = characters[this_chid]['chat'];
10951 async function postReplace() {
10952 await openCharacterChat(currentChatFile);
10953 }
10954
10955 switch (result) {
10956 case POPUP_RESULT_FILE: {
10824 async function uploadReplacementCard(e) {10957 async function uploadReplacementCard(e) {
10825 const file = e.target.files[0];10958 const file = e.target.files[0];
10826
10827 if (!file) {10959 if (!file) {
10828 return;10960 return;
10829 }10961 }
1083010962
10831 try {10963 try {
10832 const chatFile = characters[this_chid]['chat'];
10833 const data = new Map();10964 const data = new Map();
10834 data.set(file, characters[this_chid].avatar);10965 data.set(file, characters[this_chid].avatar);
10835 await processDroppedFiles([file], data);10966 await processDroppedFiles([file], data);
10836 await openCharacterChat(chatFile);10967 await postReplace();
10837 await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'reload' });
10838 } catch {10968 } catch {
10839 toastr.error('Failed to replace the character card.', 'Something went wrong');10969 toastr.error('Failed to replace the character card.', 'Something went wrong');
10840 }10970 }
10841 }10971 }
10842 $('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click');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;
10987 }
10843 }10988 }
10844 } break;10989 } break;
10845 case 'import_tags': {10990 case 'import_tags': {
@@ -10881,7 +11026,6 @@ jQuery(async function () {
10881 if (!(e.target instanceof HTMLElement)) {11026 if (!(e.target instanceof HTMLElement)) {
10882 return;11027 return;
10883 }11028 }
10884 e.target.focus();
10885 e.target.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));11029 e.target.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
10886 });11030 });
1088711031
@@ -10950,47 +11094,7 @@ jQuery(async function () {
10950 const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0);11094 const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0);
1095111095
10952 for (const url of inputs) {11096 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 }
10994 }11098 }
10995 });11099 });
1099611100
@@ -11001,6 +11105,14 @@ jQuery(async function () {
11001 await processDroppedFiles(files);11105 await processDroppedFiles(files);
11002 }, { noAnimation: true });11106 }, { 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
11004 $('#charListGridToggle').on('click', async () => {11116 $('#charListGridToggle').on('click', async () => {
11005 doCharListDisplaySwitch();11117 doCharListDisplaySwitch();
11006 });11118 });
public/scripts/RossAscends-mods.js+3 -0
@@ -766,6 +766,7 @@ export function initRossMods() {
766 }766 }
767 });767 });
768768
769 if (!isMobile()) { //only read/set pin states on non-mobile devices
769 // read the state of right Nav Lock and apply to rightnav classlist770 // read the state of right Nav Lock and apply to rightnav classlist
770 $(RPanelPin).prop('checked', accountStorage.getItem('NavLockOn') == 'true');771 $(RPanelPin).prop('checked', accountStorage.getItem('NavLockOn') == 'true');
771 if (accountStorage.getItem('NavLockOn') == 'true') {772 if (accountStorage.getItem('NavLockOn') == 'true') {
@@ -804,6 +805,8 @@ export function initRossMods() {
804 $(WorldInfo).addClass('pinnedOpen');805 $(WorldInfo).addClass('pinnedOpen');
805 $(WIDrawerIcon).addClass('drawerPinnedOpen');806 $(WIDrawerIcon).addClass('drawerPinnedOpen');
806 }807 }
808 }
809
807810
808 //save state of Right nav being open or closed811 //save state of Right nav being open or closed
809 $('#rightNavDrawerIcon').on('click', function () {812 $('#rightNavDrawerIcon').on('click', function () {
public/scripts/backgrounds.js+227 -157
@@ -3,7 +3,8 @@ import { chat_metadata, eventSource, event_types, generateQuietPrompt, getCurren
3import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';3import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
4import { SlashCommand } from './slash-commands/SlashCommand.js';4import { SlashCommand } from './slash-commands/SlashCommand.js';
5import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';5import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
6import { createThumbnail, flashHighlight, getBase64Async, stringFormat } from './utils.js';6import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop } from './utils.js';
7import { debounce_timeout } from './constants.js';
7import { t } from './i18n.js';8import { t } from './i18n.js';
8import { Popup } from './popup.js';9import { Popup } from './popup.js';
910
@@ -15,6 +16,11 @@ const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkY
15const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });16const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
16const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;17const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;
1718
19const THUMBNAIL_COLUMNS_MIN = 2;
20const THUMBNAIL_COLUMNS_MAX = 8;
21const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5;
22const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3;
23
18/**24/**
19 * Storage for frontend-generated background thumbnails.25 * Storage for frontend-generated background thumbnails.
20 * This is used to store thumbnails for backgrounds that cannot be generated on the server.26 * This is used to store thumbnails for backgrounds that cannot be generated on the server.
@@ -45,6 +51,53 @@ export let background_settings = {
45 animation: false,51 animation: false,
46};52};
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 */
59function 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 */
90function 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
48export function loadBackgroundSettings(settings) {101export function loadBackgroundSettings(settings) {
49 let backgroundSettings = settings.background;102 let backgroundSettings = settings.background;
50 if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {103 if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
@@ -56,10 +109,21 @@ export function loadBackgroundSettings(settings) {
56 if (!Object.hasOwn(backgroundSettings, 'animation')) {109 if (!Object.hasOwn(backgroundSettings, 'animation')) {
57 backgroundSettings.animation = false;110 backgroundSettings.animation = false;
58 }111 }
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
59 setBackground(backgroundSettings.name, backgroundSettings.url);122 setBackground(backgroundSettings.name, backgroundSettings.url);
60 setFittingClass(backgroundSettings.fitting);123 setFittingClass(backgroundSettings.fitting);
61 $('#background_fitting').val(backgroundSettings.fitting);124 $('#background_fitting').val(backgroundSettings.fitting);
62 $('#background_thumbnails_animation').prop('checked', background_settings.animation);125 $('#background_thumbnails_animation').prop('checked', background_settings.animation);
126 highlightSelectedBackground();
63}127}
64128
65/**129/**
@@ -68,46 +132,26 @@ export function loadBackgroundSettings(settings) {
68 */132 */
69async function forceSetBackground(backgroundInfo) {133async function forceSetBackground(backgroundInfo) {
70 saveBackgroundMetadata(backgroundInfo.url);134 saveBackgroundMetadata(backgroundInfo.url);
71 setCustomBackground();135 $('#bg1').css('background-image', backgroundInfo.url);
72136
73 const list = chat_metadata[LIST_METADATA_KEY] || [];137 const list = chat_metadata[LIST_METADATA_KEY] || [];
74 const bg = backgroundInfo.path;138 const bg = backgroundInfo.path;
75 list.push(bg);139 list.push(bg);
76 chat_metadata[LIST_METADATA_KEY] = list;140 chat_metadata[LIST_METADATA_KEY] = list;
77 saveMetadataDebounced();141 saveMetadataDebounced();
78 await getChatBackgroundsList();142 renderChatBackgrounds();
79 highlightNewBackground(bg);143 highlightNewBackground(bg);
80 highlightLockedBackground();144 highlightLockedBackground();
81}145}
82146
83async function onChatChanged() {147async 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}
94
95async function getChatBackgroundsList() {
96 const list = chat_metadata[LIST_METADATA_KEY];
97 const listEmpty = !Array.isArray(list) || list.length === 0;
98149
99 $('#bg_custom_content').empty();150 $('#bg1').css('background-image', lockedUrl || background_settings.url);
100 $('#bg_chat_hint').toggle(listEmpty);
101151
102 if (listEmpty) {152 renderChatBackgrounds();
103 return;153 highlightLockedBackground();
104 }154 highlightSelectedBackground();
105
106 for (const bg of list) {
107 const template = await getBackgroundFromTemplate(bg, true);
108 $('#bg_custom_content').append(template);
109 }
110 activateLazyLoader();
111}155}
112156
113function getBackgroundPath(fileUrl) {157function getBackgroundPath(fileUrl) {
@@ -115,59 +159,53 @@ function getBackgroundPath(fileUrl) {
115}159}
116160
117function highlightLockedBackground() {161function highlightLockedBackground() {
118 $('.bg_example').removeClass('locked');162 $('.bg_example.locked-background').removeClass('locked-background');
119163
120 const lockedBackground = chat_metadata[BG_METADATA_KEY];164 const lockedBackgroundUrl = chat_metadata[BG_METADATA_KEY];
121165
122 if (!lockedBackground) {166 if (lockedBackgroundUrl) {
123 return;167 $('.bg_example').filter(function () {
168 return $(this).data('url') === lockedBackgroundUrl;
169 }).addClass('locked-background');
124 }170 }
125
126 $('.bg_example').each(function () {
127 const url = $(this).data('url');
128 if (url === lockedBackground) {
129 $(this).addClass('locked');
130 }
131 });
132}171}
133172
134/**173/**
135 * Locks the background for the current chat174 * Locks the background for the current chat
136 * @param {Event} e Click event175 * @param {Event|null} event
137 * @returns {string} Empty string
138 */176 */
139function onLockBackgroundClick(e) {177function onLockBackgroundClick(event = null) {
140 e?.stopPropagation();178 if (!getCurrentChatId()) {
141179 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 '';
147 }181 }
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();
153 highlightLockedBackground();189 highlightLockedBackground();
154 return '';
155}190}
156191
157/**192/**
158 * Locks the background for the current chat193 * Unlocks the background for the current chat
159 * @param {Event} e Click event194 * @param {Event|null} _event
160 * @returns {string} Empty string
161 */195 */
162function onUnlockBackgroundClick(e) {196function onUnlockBackgroundClick(_event = null) {
163 e?.stopPropagation();197 // Delete the lock from the chat's metadata.
164 removeBackgroundMetadata();198 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.
166 highlightLockedBackground();204 highlightLockedBackground();
167 return '';205 highlightSelectedBackground();
168}206}
169207
170function hasCustomBackground() {208function isChatBackgroundLocked() {
171 return chat_metadata[BG_METADATA_KEY];209 return chat_metadata[BG_METADATA_KEY];
172}210}
173211
@@ -181,54 +219,22 @@ function removeBackgroundMetadata() {
181 saveMetadataDebounced();219 saveMetadataDebounced();
182}220}
183221
184function 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
195function unsetCustomBackground() {
196 $('#bg_custom').css('background-image', 'none');
197}
198
199function onSelectBackgroundClick() {222function onSelectBackgroundClick() {
200 const isCustom = $(this).attr('custom') === 'true';223 const bgFile = $(this).attr('bgfile');
201 const relativeBgImage = getUrlParameter(this);224 const backgroundCssUrl = 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 locked226 if (isChatBackgroundLocked()) {
209 if (hasCustomBackground() || isCustom) {227 // If a background is locked, update the locked background directly
210 saveBackgroundMetadata(relativeBgImage);228 saveBackgroundMetadata(backgroundCssUrl);
211 setCustomBackground();229 $('#bg1').css('background-image', backgroundCssUrl);
212 highlightLockedBackground();230 highlightLockedBackground();
231 } else {
232 // Otherwise, update the global background setting
233 setBackground(bgFile, backgroundCssUrl);
213 }234 }
214 highlightLockedBackground();
215
216 const customBg = window.getComputedStyle(document.getElementById('bg_custom')).backgroundImage;
217235
218 // Custom background is set. Do not override the layer below236 // Update UI highlights to reflect the changes.
219 if (customBg !== 'none') {237 highlightSelectedBackground();
220 return;
221 }
222
223 const bgFile = $(this).attr('bgfile');
224 const backgroundUrl = getBackgroundPath(bgFile);
225
226 // Fetching to browser memory to reduce flicker
227 fetch(backgroundUrl).then(() => {
228 setBackground(bgFile, relativeBgImage);
229 }).catch(() => {
230 console.log('Background could not be set: ' + backgroundUrl);
231 });
232}238}
233239
234async function onCopyToSystemBackgroundClick(e) {240async function onCopyToSystemBackgroundClick(e) {
@@ -257,7 +263,7 @@ async function onCopyToSystemBackgroundClick(e) {
257 const index = list.indexOf(bgNames.oldBg);263 const index = list.indexOf(bgNames.oldBg);
258 list.splice(index, 1);264 list.splice(index, 1);
259 saveMetadataDebounced();265 saveMetadataDebounced();
260 await getChatBackgroundsList();266 renderChatBackgrounds();
261}267}
262268
263/**269/**
@@ -382,29 +388,32 @@ async function onDeleteBackgroundClick(e) {
382 list.splice(index, 1);388 list.splice(index, 1);
383 }389 }
384390
385 const siblingSelector = '.bg_example:not(#form_bg_download)';391 if (bg === background_settings.name) {
392 const siblingSelector = '.bg_example';
386 const nextBg = bgToDelete.next(siblingSelector);393 const nextBg = bgToDelete.next(siblingSelector);
387 const prevBg = bgToDelete.prev(siblingSelector);394 const prevBg = bgToDelete.prev(siblingSelector);
388 const anyBg = $(siblingSelector);
389395
390 if (nextBg.length > 0) {396 if (nextBg.length > 0) {
391 nextBg.trigger('click');397 nextBg.trigger('click');
392 } else if (prevBg.length > 0) {398 } else if (prevBg.length > 0) {
393 prevBg.trigger('click');399 prevBg.trigger('click');
394 } else {400 } 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 }
396 }406 }
397407
398 bgToDelete.remove();408 bgToDelete.remove();
399409
400 if (url === chat_metadata[BG_METADATA_KEY]) {410 if (url === chat_metadata[BG_METADATA_KEY]) {
401 removeBackgroundMetadata();411 removeBackgroundMetadata();
402 unsetCustomBackground();
403 highlightLockedBackground();412 highlightLockedBackground();
404 }413 }
405414
406 if (isCustom) {415 if (isCustom) {
407 await getChatBackgroundsList();416 renderChatBackgrounds();
408 saveMetadataDebounced();417 saveMetadataDebounced();
409 }418 }
410 }419 }
@@ -445,6 +454,47 @@ async function autoBackgroundCommand() {
445 return '';454 return '';
446}455}
447456
457/**
458 * Renders the system backgrounds gallery.
459 * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
460 */
461function 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 */
481function 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
448export async function getBackgrounds() {498export async function getBackgrounds() {
449 const response = await fetch('/api/backgrounds/all', {499 const response = await fetch('/api/backgrounds/all', {
450 method: 'POST',500 method: 'POST',
@@ -454,12 +504,9 @@ export async function getBackgrounds() {
454 if (response.ok) {504 if (response.ok) {
455 const { images, config } = await response.json();505 const { images, config } = await response.json();
456 Object.assign(THUMBNAIL_CONFIG, config);506 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();
463 }510 }
464}511}
465512
@@ -481,14 +528,19 @@ function activateLazyLoader() {
481 lazyLoadObserver = new IntersectionObserver((entries, observer) => {528 lazyLoadObserver = new IntersectionObserver((entries, observer) => {
482 entries.forEach(entry => {529 entries.forEach(entry => {
483 if (entry.target instanceof HTMLElement && entry.isIntersecting) {530 if (entry.target instanceof HTMLElement && entry.isIntersecting) {
484 const target = entry.target;531 const clipper = entry.target;
485 const bg = target.getAttribute('bgfile');532 const parentThumbnail = clipper.closest('.bg_example');
486 const isCustom = target.getAttribute('custom') === 'true';533
534 if (parentThumbnail) {
535 const bg = parentThumbnail.getAttribute('bgfile');
536 const isCustom = parentThumbnail.getAttribute('custom') === 'true';
487 resolveImageUrl(bg, isCustom)537 resolveImageUrl(bg, isCustom)
488 .then(url => { target.style.backgroundImage = url; })538 .then(url => { clipper.style.backgroundImage = url; })
489 .catch(() => { target.style.backgroundImage = PLACEHOLDER_IMAGE; });539 .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
490 target.classList.remove('lazy-load-background');540 }
491 observer.unobserve(target);541
542 clipper.classList.remove('lazy-load-background');
543 observer.unobserve(clipper);
492 }544 }
493 });545 });
494 }, options);546 }, options);
@@ -529,30 +581,11 @@ async function resolveImageUrl(bg, isCustom) {
529 return `url("${thumbnailUrl}")`;581 return `url("${thumbnailUrl}")`;
530}582}
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 */
538async 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
554async function setBackground(bg, url) {584async function setBackground(bg, url) {
585 // Only change the visual background if one is not locked for the current chat.
586 if (!isChatBackgroundLocked()) {
555 $('#bg1').css('background-image', url);587 $('#bg1').css('background-image', url);
588 }
556 background_settings.name = bg;589 background_settings.name = bg;
557 background_settings.url = url;590 background_settings.url = url;
558 saveSettingsDebounced();591 saveSettingsDebounced();
@@ -680,25 +713,39 @@ function highlightNewBackground(bg) {
680 * @param {string} fitting Fitting type713 * @param {string} fitting Fitting type
681 */714 */
682function setFittingClass(fitting) {715function setFittingClass(fitting) {
683 const backgrounds = $('#bg1, #bg_custom');716 const backgrounds = $('#bg1');
684 for (const option of ['cover', 'contain', 'stretch', 'center']) {717 for (const option of ['cover', 'contain', 'stretch', 'center']) {
685 backgrounds.toggleClass(option, option === fitting);718 backgrounds.toggleClass(option, option === fitting);
686 }719 }
687 background_settings.fitting = fitting;720 background_settings.fitting = fitting;
688}721}
689722
690function onBackgroundFilterInput() {723function highlightSelectedBackground() {
691 const filterValue = String($(this).val()).toLowerCase();724 $('.bg_example.selected-background').removeClass('selected-background');
692 $('#bg_menu_content > div').each(function () {725
693 const $bgContent = $(this);726 // The "selected" highlight should always reflect the global background setting.
694 if ($bgContent.attr('title').toLowerCase().includes(filterValue)) {727 const activeUrl = background_settings.url;
695 $bgContent.show();728
696 } else {729 if (activeUrl) {
697 $bgContent.hide();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');
698 }734 }
735}
736
737function onBackgroundFilterInput() {
738 const filterValue = String($('#bg-filter').val()).toLowerCase();
739 $('#bg_menu_content > .bg_example, #bg_custom_content > .bg_example').each(function () {
740 const $bg = $(this);
741 const title = $bg.attr('title') || '';
742 const hasMatch = title.toLowerCase().includes(filterValue);
743 $bg.toggle(hasMatch);
699 });744 });
700}745}
701746
747const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
748
702export function initBackgrounds() {749export function initBackgrounds() {
703 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);750 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
704 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);751 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
@@ -715,6 +762,11 @@ export function initBackgrounds() {
715 $context.addClass('mobile-menu-open');762 $context.addClass('mobile-menu-open');
716 }763 }
717 })764 })
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 })
718 .off('click', '.jg-button').on('click', '.jg-button', function (e) {770 .off('click', '.jg-button').on('click', '.jg-button', function (e) {
719 e.stopPropagation();771 e.stopPropagation();
720 const action = $(this).data('action');772 const action = $(this).data('action');
@@ -738,18 +790,30 @@ export function initBackgrounds() {
738 }790 }
739 });791 });
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 });
741 $('#auto_background').on('click', autoBackgroundCommand);799 $('#auto_background').on('click', autoBackgroundCommand);
742 $('#add_bg_button').on('change', onBackgroundUploadSelected);800 $('#add_bg_button').on('change', onBackgroundUploadSelected);
743 $('#bg-filter').on('input', onBackgroundFilterInput);801 $('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
744 SlashCommandParser.addCommandObject(SlashCommand.fromProps({802 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
745 name: 'lockbg',803 name: 'lockbg',
746 callback: () => onLockBackgroundClick(new CustomEvent('click')),804 callback: () => {
805 onLockBackgroundClick();
806 return '';
807 },
747 aliases: ['bglock'],808 aliases: ['bglock'],
748 helpString: 'Locks a background for the currently selected chat',809 helpString: 'Locks a background for the currently selected chat',
749 }));810 }));
750 SlashCommandParser.addCommandObject(SlashCommand.fromProps({811 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
751 name: 'unlockbg',812 name: 'unlockbg',
752 callback: () => onUnlockBackgroundClick(new CustomEvent('click')),813 callback: () => {
814 onUnlockBackgroundClick();
815 return '';
816 },
753 aliases: ['bgunlock'],817 aliases: ['bgunlock'],
754 helpString: 'Unlocks a background for the currently selected chat',818 helpString: 'Unlocks a background for the currently selected chat',
755 }));819 }));
@@ -774,4 +838,10 @@ export function initBackgrounds() {
774 await getBackgrounds();838 await getBackgrounds();
775 await onChatChanged();839 await onChatChanged();
776 });840 });
841
842 setupScrollToTop({
843 scrollContainerId: 'bg-scrollable-content',
844 buttonId: 'bg-scroll-top',
845 drawerId: 'Backgrounds',
846 });
777}847}
public/scripts/chats.js+2 -3
@@ -750,10 +750,9 @@ function getStyleContentsFromMarkdown(text) {
750 return '';750 return '';
751 }751 }
752752
753 const div = document.createElement('div');
754 const html = converter.makeHtml(substituteParams(text));753 const html = converter.makeHtml(substituteParams(text));
755 div.innerHTML = html;754 const parsedDocument = new DOMParser().parseFromString(html, 'text/html');
756 const styleElements = Array.from(div.querySelectorAll('style'));755 const styleElements = Array.from(parsedDocument.querySelectorAll('style'));
757 return styleElements756 return styleElements
758 .filter(s => s.textContent.trim().length > 0)757 .filter(s => s.textContent.trim().length > 0)
759 .map(s => s.textContent.trim())758 .map(s => s.textContent.trim())
public/scripts/constants.js+1 -0
@@ -51,6 +51,7 @@ export const inject_ids = {
51 DEPTH_PROMPT_INDEX: (index) => `DEPTH_PROMPT_${index}`,51 DEPTH_PROMPT_INDEX: (index) => `DEPTH_PROMPT_${index}`,
52 CUSTOM_WI_DEPTH: 'customDepthWI',52 CUSTOM_WI_DEPTH: 'customDepthWI',
53 CUSTOM_WI_DEPTH_ROLE: (depth, role) => `customDepthWI_${depth}_${role}`,53 CUSTOM_WI_DEPTH_ROLE: (depth, role) => `customDepthWI_${depth}_${role}`,
54 CUSTOM_WI_OUTLET: (key) => `customWIOutlet_${key}`,
54};55};
5556
56export const COMETAPI_IGNORE_PATTERNS = [57export const COMETAPI_IGNORE_PATTERNS = [
public/scripts/custom-request.js+3 -1
@@ -49,6 +49,7 @@ import EventSourceStream from './sse-stream.js';
49 * @property {string} [custom_url] - Optional custom URL49 * @property {string} [custom_url] - Optional custom URL
50 * @property {string} [reverse_proxy] - Optional reverse proxy URL50 * @property {string} [reverse_proxy] - Optional reverse proxy URL
51 * @property {string} [proxy_password] - Optional proxy password51 * @property {string} [proxy_password] - Optional proxy password
52 * @property {string} [custom_prompt_post_processing] - Optional custom prompt post-processing
52 */53 */
5354
54/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */55/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */
@@ -414,7 +415,7 @@ export class ChatCompletionService {
414 * @param {ChatCompletionPayload} custom415 * @param {ChatCompletionPayload} custom
415 * @returns {ChatCompletionPayload}416 * @returns {ChatCompletionPayload}
416 */417 */
417 static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, reverse_proxy, proxy_password, ...props }) {418 static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, reverse_proxy, proxy_password, custom_prompt_post_processing, ...props }) {
418 const payload = {419 const payload = {
419 stream,420 stream,
420 messages,421 messages,
@@ -425,6 +426,7 @@ export class ChatCompletionService {
425 custom_url,426 custom_url,
426 reverse_proxy,427 reverse_proxy,
427 proxy_password,428 proxy_password,
429 custom_prompt_post_processing,
428 use_makersuite_sysprompt: true,430 use_makersuite_sysprompt: true,
429 claude_use_sysprompt: true,431 claude_use_sysprompt: true,
430 ...props,432 ...props,
public/scripts/dom-handlers.js+66 -0
@@ -0,0 +1,66 @@
1import { throttle } from './utils.js';
2
3export 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 */
12function 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 = {
12 MESSAGE_FILE_EMBEDDED: 'message_file_embedded',12 MESSAGE_FILE_EMBEDDED: 'message_file_embedded',
13 MESSAGE_REASONING_EDITED: 'message_reasoning_edited',13 MESSAGE_REASONING_EDITED: 'message_reasoning_edited',
14 MESSAGE_REASONING_DELETED: 'message_reasoning_deleted',14 MESSAGE_REASONING_DELETED: 'message_reasoning_deleted',
15 MESSAGE_SWIPE_DELETED: 'message_swipe_deleted',
15 MORE_MESSAGES_LOADED: 'more_messages_loaded',16 MORE_MESSAGES_LOADED: 'more_messages_loaded',
16 IMPERSONATE_READY: 'impersonate_ready',17 IMPERSONATE_READY: 'impersonate_ready',
17 CHAT_CHANGED: 'chat_id_changed',18 CHAT_CHANGED: 'chat_id_changed',
@@ -36,6 +37,7 @@ export const event_types = {
36 OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready',37 OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready',
37 WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',38 WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
38 WORLDINFO_UPDATED: 'worldinfo_updated',39 WORLDINFO_UPDATED: 'worldinfo_updated',
40 CHARACTER_EDITOR_OPENED: 'character_editor_opened',
39 CHARACTER_EDITED: 'character_edited',41 CHARACTER_EDITED: 'character_edited',
40 CHARACTER_PAGE_LOADED: 'character_page_loaded',42 CHARACTER_PAGE_LOADED: 'character_page_loaded',
41 CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',43 CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',
@@ -85,6 +87,8 @@ export const event_types = {
85 SECRET_EDITED: 'secret_edited',87 SECRET_EDITED: 'secret_edited',
86 PRESET_CHANGED: 'preset_changed',88 PRESET_CHANGED: 'preset_changed',
87 PRESET_DELETED: 'preset_deleted',89 PRESET_DELETED: 'preset_deleted',
90 PRESET_RENAMED: 'preset_renamed',
91 PRESET_RENAMED_BEFORE: 'preset_renamed_before',
88 MAIN_API_CHANGED: 'main_api_changed',92 MAIN_API_CHANGED: 'main_api_changed',
89 WORLDINFO_ENTRIES_LOADED: 'worldinfo_entries_loaded',93 WORLDINFO_ENTRIES_LOADED: 'worldinfo_entries_loaded',
90};94};
public/scripts/extensions.js+3 -0
@@ -190,7 +190,10 @@ export const extension_settings = {
190 regex: [],190 regex: [],
191 /** @type {import('./extensions/regex/index.js').RegexPreset[]} */191 /** @type {import('./extensions/regex/index.js').RegexPreset[]} */
192 regex_presets: [],192 regex_presets: [],
193 /** @type {string[]} */
193 character_allowed_regex: [],194 character_allowed_regex: [],
195 /** @type {Record<string, string[]>} */
196 preset_allowed_regex: {},
194 tts: {},197 tts: {},
195 sd: {198 sd: {
196 prompts: {},199 prompts: {},
public/scripts/extensions/assets/index.js+37 -3
@@ -10,7 +10,7 @@ import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
10import { executeSlashCommandsWithOptions } from '../../slash-commands.js';10import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
11import { accountStorage } from '../../util/AccountStorage.js';11import { accountStorage } from '../../util/AccountStorage.js';
12import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';12import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
13import { t } from '../../i18n.js';13import { t, translate } from '../../i18n.js';
14export { MODULE_NAME };14export { MODULE_NAME };
1515
16const MODULE_NAME = 'assets';16const MODULE_NAME = 'assets';
@@ -60,6 +60,36 @@ const KNOWN_TYPES = {
60 'blip': t`Blip sounds`,60 'blip': t`Blip sounds`,
61};61};
6262
63const 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 */
73function 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
63async function downloadAssetsList(url) {93async function downloadAssetsList(url) {
64 updateCurrentAssets().then(async function () {94 updateCurrentAssets().then(async function () {
65 fetch(url, { cache: 'no-cache' })95 fetch(url, { cache: 'no-cache' })
@@ -88,7 +118,8 @@ async function downloadAssetsList(url) {
88 $('#assets_type_select').append($('<option />', { value: '', text: t`All` }));118 $('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
89119
90 for (const type of assetTypes) {120 for (const type of assetTypes) {
91 const option = $('<option />', { value: type, text: t([KNOWN_TYPES[type] || type]) });121 const text = translate(KNOWN_TYPES[type] || type);
122 const option = $('<option />', { value: type, text: text });
92 $('#assets_type_select').append(option);123 $('#assets_type_select').append(option);
93 }124 }
94125
@@ -184,10 +215,11 @@ async function downloadAssetsList(url) {
184 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;215 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
185 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';216 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
186 const toolTag = assetType === 'extension' && asset['tool'];217 const toolTag = assetType === 'extension' && asset['tool'];
218 const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
187219
188 const assetBlock = $('<i></i>')220 const assetBlock = $('<i></i>')
189 .append(element)221 .append(element)
190 .append(`<div class="flex-container flexFlowColumn flexNoGap">222 .append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden">
191 <span class="asset-name flex-container alignitemscenter">223 <span class="asset-name flex-container alignitemscenter">
192 <b>${displayName}</b>224 <b>${displayName}</b>
193 <a class="asset_preview" href="${url}" target="_blank" title="${title}">225 <a class="asset_preview" href="${url}" target="_blank" title="${title}">
@@ -195,6 +227,8 @@ async function downloadAssetsList(url) {
195 </a>` +227 </a>` +
196 (toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +228 (toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
197 t`Tool` + '</span>' : '') +229 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>` : '') +
198 `</span>232 `</span>
199 <small class="asset-description">233 <small class="asset-description">
200 ${description}234 ${description}
public/scripts/extensions/assets/style.css+26 -1
@@ -40,9 +40,10 @@
40 flex-direction: row;40 flex-direction: row;
41 align-items: center;41 align-items: center;
42 justify-content: left;42 justify-content: left;
43 padding: 5px;43 padding: 10px 5px;
44 font-style: normal;44 font-style: normal;
45 gap: 5px;45 gap: 5px;
46 border-bottom: 1px solid var(--SmartThemeBorderColor);
46}47}
4748
48.assets-list-div i span:first-of-type {49.assets-list-div i span:first-of-type {
@@ -173,3 +174,27 @@
173 opacity: 0.9;174 opacity: 0.9;
174 margin-left: 2px;175 margin-left: 2px;
175}176}
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 @@
1import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js';1import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
2import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';2import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
3import { appendMediaToMessage, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';3import { appendMediaToMessage, chat_metadata, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
4import { getMessageTimeStamp } from '../../RossAscends-mods.js';4import { getMessageTimeStamp } from '../../RossAscends-mods.js';
5import { SECRET_KEYS, secret_state } from '../../secrets.js';5import { SECRET_KEYS, secret_state } from '../../secrets.js';
6import { getMultimodalCaption } from '../shared.js';6import { getMultimodalCaption } from '../shared.js';
@@ -174,6 +174,7 @@ async function sendCaptionedMessage(caption, image) {
174 inline_image: !!extension_settings.caption.show_in_chat,174 inline_image: !!extension_settings.caption.show_in_chat,
175 },175 },
176 };176 };
177 chat_metadata['tainted'] = true;
177 context.chat.push(message);178 context.chat.push(message);
178 const messageId = context.chat.length - 1;179 const messageId = context.chat.length - 1;
179 await eventSource.emit(event_types.MESSAGE_SENT, messageId);180 await eventSource.emit(event_types.MESSAGE_SENT, messageId);
@@ -549,6 +550,8 @@ jQuery(async function () {
549 await processEndpoint('pollinations', '/api/backends/chat-completions/multimodal-models/pollinations');550 await processEndpoint('pollinations', '/api/backends/chat-completions/multimodal-models/pollinations');
550 await processEndpoint('nanogpt', '/api/backends/chat-completions/multimodal-models/nanogpt');551 await processEndpoint('nanogpt', '/api/backends/chat-completions/multimodal-models/nanogpt');
551 await processEndpoint('electronhub', '/api/backends/chat-completions/multimodal-models/electronhub');552 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');
552 }555 }
553556
554 await addSettings();557 await addSettings();
@@ -626,6 +629,11 @@ jQuery(async function () {
626 extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();629 extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();
627 saveSettingsDebounced();630 saveSettingsDebounced();
628 });631 });
632 $('#caption_refresh_models').on('click', async () => {
633 extension_settings.caption.multimodal_model = '';
634 await switchMultimodalBlocks();
635 saveSettingsDebounced();
636 });
629637
630 const onMessageEvent = async (index) => {638 const onMessageEvent = async (index) => {
631 if (!extension_settings.caption.auto_mode) {639 if (!extension_settings.caption.auto_mode) {
public/scripts/extensions/caption/settings.html+24 -45
@@ -9,7 +9,7 @@
9 <label for="caption_source" data-i18n="Source">Source</label>9 <label for="caption_source" data-i18n="Source">Source</label>
10 <select id="caption_source" class="text_pole">10 <select id="caption_source" class="text_pole">
11 <option value="local" data-i18n="Local">Local</option>11 <option value="local" data-i18n="Local">Local</option>
12 <option value="multimodal" data-i18n="Multimodal (OpenAI / Anthropic / llama / Google)">Multimodal (OpenAI / Anthropic / llama / Google)</option>12 <option value="multimodal" data-i18n="Multimodal">Multimodal</option>
13 <option value="extras" data-i18n="Extras">Extras (deprecated)</option>13 <option value="extras" data-i18n="Extras">Extras (deprecated)</option>
14 <option value="horde" data-i18n="Horde">Horde</option>14 <option value="horde" data-i18n="Horde">Horde</option>
15 </select>15 </select>
@@ -18,7 +18,7 @@
18 <label for="caption_multimodal_api" data-i18n="API">API</label>18 <label for="caption_multimodal_api" data-i18n="API">API</label>
19 <select id="caption_multimodal_api" class="flex1 text_pole">19 <select id="caption_multimodal_api" class="flex1 text_pole">
20 <option value="aimlapi">AI/ML API</option>20 <option value="aimlapi">AI/ML API</option>
21 <option value="anthropic">Anthropic</option>21 <option value="anthropic">Claude</option>
22 <option value="cohere">Cohere</option>22 <option value="cohere">Cohere</option>
23 <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>23 <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
24 <option value="electronhub">Electron Hub</option>24 <option value="electronhub">Electron Hub</option>
@@ -40,23 +40,17 @@
40 </select>40 </select>
41 </div>41 </div>
42 <div class="flex1 flex-container flexFlowColumn flexNoGap">42 <div class="flex1 flex-container flexFlowColumn flexNoGap">
43 <label for="caption_multimodal_model" data-i18n="Model">Model</label>43 <label for="caption_multimodal_model" class="flex-container justifySpaceBetween">
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>
44 <select id="caption_multimodal_model" class="flex1 text_pole">49 <select id="caption_multimodal_model" class="flex1 text_pole">
45 <!-- AI/ML API, OpenRouter, Pollinations, NanoGPT are added externally by JavaScript -->50 <!-- AI/ML API, OpenRouter, Pollinations, NanoGPT, Mistral, xAI are added externally by JavaScript -->
46 <option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>51 <option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>
47 <option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>52 <option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>
48 <option data-type="cohere" value="command-a-vision-07-2025">command-a-vision-07-2025</option>53 <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>
60 <option data-type="moonshot" value="moonshot-v1-8k-vision-preview">moonshot-v1-8k-vision-preview</option>54 <option data-type="moonshot" value="moonshot-v1-8k-vision-preview">moonshot-v1-8k-vision-preview</option>
61 <option data-type="moonshot" value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>55 <option data-type="moonshot" value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>
62 <option data-type="moonshot" value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>56 <option data-type="moonshot" value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>
@@ -87,6 +81,10 @@
87 <option data-type="openai" value="o4-mini-2025-04-16">o4-mini-2025-04-16</option>81 <option data-type="openai" value="o4-mini-2025-04-16">o4-mini-2025-04-16</option>
88 <option data-type="openai" value="gpt-4.5-preview">gpt-4.5-preview</option>82 <option data-type="openai" value="gpt-4.5-preview">gpt-4.5-preview</option>
89 <option data-type="openai" value="gpt-4.5-preview-2025-02-27">gpt-4.5-preview-2025-02-27</option>83 <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>
90 <option data-type="anthropic" value="claude-opus-4-1">claude-opus-4-1</option>88 <option data-type="anthropic" value="claude-opus-4-1">claude-opus-4-1</option>
91 <option data-type="anthropic" value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>89 <option data-type="anthropic" value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>
92 <option data-type="anthropic" value="claude-opus-4-0">claude-opus-4-0</option>90 <option data-type="anthropic" value="claude-opus-4-0">claude-opus-4-0</option>
@@ -106,49 +104,33 @@
106 <option data-type="google" value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>104 <option data-type="google" value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
107 <option data-type="google" value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>105 <option data-type="google" value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
108 <option data-type="google" value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>106 <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>
110 <option data-type="google" value="gemini-2.5-flash">gemini-2.5-flash</option>107 <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>
111 <option data-type="google" value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>109 <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>
113 <option data-type="google" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>110 <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>
114 <option data-type="google" value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>112 <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>
115 <option data-type="google" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>114 <option data-type="google" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
116 <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>115 <option data-type="google" value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-exp-03-25</option>
117 <option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-pro-exp-03-25</option>116 <option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-exp-03-25</option>
118 <option data-type="google" value="gemini-exp-1206">gemini-exp-1206 → 2.5-pro-exp-03-25</option>117 <option data-type="google" value="gemini-exp-1206">gemini-exp-1206 → 2.5-exp-03-25</option>
119 <option data-type="google" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>118 <option data-type="google" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
120 <option data-type="google" value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>119 <option data-type="google" value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>
121 <option data-type="google" value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>120 <option data-type="google" value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
122 <option data-type="google" value="gemini-2.0-flash">gemini-2.0-flash</option>121 <option data-type="google" value="gemini-2.0-flash">gemini-2.0-flash</option>
123 <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-04-17</option>122 <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-5-20</option>
124 <option data-type="google" value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-04-17</option>123 <option data-type="google" value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-05-20</option>
125 <option data-type="google" value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-04-17</option>124 <option data-type="google" value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-05-20</option>
126 <option data-type="google" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>125 <option data-type="google" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
127 <option data-type="google" value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>126 <option data-type="google" value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>
128 <option data-type="google" value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>127 <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>
141 <option data-type="google" value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>128 <option data-type="google" value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>
142 <option data-type="google" value="learnlm-1.5-pro-experimental">learnlm-1.5-pro-experimental</option>129 <option data-type="google" value="gemini-robotics-er-1.5-preview">gemini-robotics-er-1.5-preview</option>
143 <option data-type="vertexai" value="gemini-2.5-pro">gemini-2.5-pro</option>130 <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>
147 <option data-type="vertexai" value="gemini-2.5-flash">gemini-2.5-flash</option>131 <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>
150 <option data-type="vertexai" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>132 <option data-type="vertexai" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
151 <option data-type="vertexai" value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>133 <option data-type="vertexai" value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
152 <option data-type="vertexai" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>134 <option data-type="vertexai" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
153 <option data-type="vertexai" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>135 <option data-type="vertexai" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
154 <option data-type="vertexai" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>136 <option data-type="vertexai" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
@@ -174,9 +156,6 @@
174 <option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>156 <option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
175 <option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>157 <option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
176 <option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>158 <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>
180 </select>159 </select>
181 </div>160 </div>
182 <div data-type="ollama">161 <div data-type="ollama">
public/scripts/extensions/connection-manager/index.js+22 -2
@@ -1,6 +1,6 @@
1import { DOMPurify, Fuse } from '../../../lib.js';1import { DOMPurify, Fuse } from '../../../lib.js';
22
3import { event_types, eventSource, main_api, saveSettingsDebounced } from '../../../script.js';3import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
4import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';4import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
6import { SlashCommand } from '../../slash-commands/SlashCommand.js';6import { SlashCommand } from '../../slash-commands/SlashCommand.js';
@@ -11,7 +11,7 @@ import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDe
11import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';11import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
12import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';12import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
13import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';13import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
14import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';14import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
15import { t } from '../../i18n.js';15import { t } from '../../i18n.js';
16import { getSecretLabelById } from '../../secrets.js';16import { getSecretLabelById } from '../../secrets.js';
1717
@@ -167,6 +167,12 @@ const profilesProvider = () => [
167 * @property {string} [stop-strings] Custom Stopping Strings167 * @property {string} [stop-strings] Custom Stopping Strings
168 * @property {string} [start-reply-with] Start Reply With168 * @property {string} [start-reply-with] Start Reply With
169 * @property {string} [reasoning-template] Reasoning Template169 * @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
170 * @property {string[]} [exclude] Commands to exclude176 * @property {string[]} [exclude] Commands to exclude
171 */177 */
172178
@@ -684,6 +690,13 @@ async function renderDetailsContent(detailsContent) {
684 defaultValue: 'true',690 defaultValue: 'true',
685 enumList: commonEnumProviders.boolean('trueFalse')(),691 enumList: commonEnumProviders.boolean('trueFalse')(),
686 }),692 }),
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 }),
687 ],700 ],
688 callback: async (args, value) => {701 callback: async (args, value) => {
689 if (!value || typeof value !== 'string') {702 if (!value || typeof value !== 'string') {
@@ -715,6 +728,13 @@ async function renderDetailsContent(detailsContent) {
715728
716 if (shouldAwait) {729 if (shouldAwait) {
717 await awaitPromise;730 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 }
718 }738 }
719739
720 return profile.name;740 return profile.name;
public/scripts/extensions/memory/index.js+1 -1
@@ -993,7 +993,7 @@ function doPopout(e) {
993 originalElement.empty();993 originalElement.empty();
994 originalElement.html('<div class="flex-container alignitemscenter justifyCenter wide100p"><small>Currently popped out</small></div>');994 originalElement.html('<div class="flex-container alignitemscenter justifyCenter wide100p"><small>Currently popped out</small></div>');
995 newElement.append(controlBarHtml).append(originalHTMLClone);995 newElement.append(controlBarHtml).append(originalHTMLClone);
996 $('body').append(newElement);996 $('#movingDivs').append(newElement);
997 newElement.transition({ opacity: 1, duration: animation_duration, easing: animation_easing });997 newElement.transition({ opacity: 1, duration: animation_duration, easing: animation_easing });
998 $('#summaryExtensionDrawerContents').addClass('scrollableInnerFull');998 $('#summaryExtensionDrawerContents').addClass('scrollableInnerFull');
999 setMemoryContext(prevSummaryBoxContents, false); //paste prev summary box contents into popout box999 setMemoryContext(prevSummaryBoxContents, false); //paste prev summary box contents into popout box
public/scripts/extensions/regex/debugger.html+5 -1
@@ -11,6 +11,7 @@
11 <button11 <button
12 id="regex_debugger_save_order"12 id="regex_debugger_save_order"
13 class="menu_button menu_button_icon interactable"13 class="menu_button menu_button_icon interactable"
14 data-i18n="[title]ext_regex_debugger_save_order_help"
14 title="Save current rule order"15 title="Save current rule order"
15 tabindex="0"16 tabindex="0"
16 >17 >
@@ -53,6 +54,7 @@
53 <button54 <button
54 id="regex_debugger_run_test"55 id="regex_debugger_run_test"
55 class="menu_button menu_button_icon interactable"56 class="menu_button menu_button_icon interactable"
57 data-i18n="[title]ext_regex_debugger_run_test_help"
56 title="Run the test pipeline"58 title="Run the test pipeline"
57 tabindex="0"59 tabindex="0"
58 >60 >
@@ -115,6 +117,7 @@
115 <div117 <div
116 id="regex_debugger_expand_steps"118 id="regex_debugger_expand_steps"
117 class="menu_button menu_button_icon"119 class="menu_button menu_button_icon"
120 data-i18n="[title]Expand view"
118 title="Expand view"121 title="Expand view"
119 >122 >
120 <i class="fa-solid fa-expand"></i>123 <i class="fa-solid fa-expand"></i>
@@ -132,6 +135,7 @@
132 <div135 <div
133 id="regex_debugger_expand_final"136 id="regex_debugger_expand_final"
134 class="menu_button menu_button_icon"137 class="menu_button menu_button_icon"
138 data-i18n="[title]Expand view"
135 title="Expand view"139 title="Expand view"
136 >140 >
137 <i class="fa-solid fa-expand"></i>141 <i class="fa-solid fa-expand"></i>
@@ -158,7 +162,7 @@
158 <code class="rule-regex"></code>162 <code class="rule-regex"></code>
159 <small class="rule-scope"></small>163 <small class="rule-scope"></small>
160 </div>164 </div>
161 <div class="menu_button menu_button_icon edit_rule" title="Edit Rule">165 <div class="menu_button menu_button_icon edit_rule" data-i18n="[title]Edit Rule" title="Edit Rule">
162 <i class="fa-solid fa-pencil"></i>166 <i class="fa-solid fa-pencil"></i>
163 </div>167 </div>
164 </li>168 </li>
public/scripts/extensions/regex/dropdown.html+35 -3
@@ -16,6 +16,10 @@
16 <i class="fa-solid fa-address-card"></i>16 <i class="fa-solid fa-address-card"></i>
17 <small data-i18n="ext_regex_new_scoped_script">+ Scoped</small>17 <small data-i18n="ext_regex_new_scoped_script">+ Scoped</small>
18 </div>18 </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>
19 <div id="import_regex" class="menu_button menu_button_icon">23 <div id="import_regex" class="menu_button menu_button_icon">
20 <i class="fa-solid fa-file-import"></i>24 <i class="fa-solid fa-file-import"></i>
21 <small data-i18n="ext_regex_import_script">Import</small>25 <small data-i18n="ext_regex_import_script">Import</small>
@@ -31,7 +35,8 @@
31 <small data-i18n="ext_regex_debugger">Debugger</small>35 <small data-i18n="ext_regex_debugger">Debugger</small>
32 </div>36 </div>
33 </div>37 </div>
34 <div class="regex_bulk_operations flex-container justifyCenter">38 <hr class="regex_bulk_operations_hr" />
39 <div class="regex_bulk_operations flex-container">
35 <div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">40 <div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">
36 <i class="fa-solid fa-check-double"></i>41 <i class="fa-solid fa-check-double"></i>
37 </div>42 </div>
@@ -43,6 +48,18 @@
43 <i class="fa-solid fa-toggle-off"></i>48 <i class="fa-solid fa-toggle-off"></i>
44 <small data-i18n="Disable">Disable</small>49 <small data-i18n="Disable">Disable</small>
45 </div>50 </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>
46 <div id="bulk_export_regex" class="menu_button menu_button_icon">63 <div id="bulk_export_regex" class="menu_button menu_button_icon">
47 <i class="fa-solid fa-file-export"></i>64 <i class="fa-solid fa-file-export"></i>
48 <small data-i18n="Export">Export</small>65 <small data-i18n="Export">Export</small>
@@ -69,7 +86,7 @@
69 </div>86 </div>
70 </div>87 </div>
71 <hr />88 <hr />
72 <div id="global_scripts_block" class="padding5">89 <div id="global_scripts_block">
73 <div>90 <div>
74 <strong data-i18n="ext_regex_global_scripts">Global Scripts</strong>91 <strong data-i18n="ext_regex_global_scripts">Global Scripts</strong>
75 </div>92 </div>
@@ -79,7 +96,7 @@
79 <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>96 <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>
80 </div>97 </div>
81 <hr />98 <hr />
82 <div id="scoped_scripts_block" class="padding5">99 <div id="scoped_scripts_block">
83 <div class="flex-container alignItemsBaseline">100 <div class="flex-container alignItemsBaseline">
84 <strong class="flex1" data-i18n="ext_regex_scoped_scripts">Scoped Scripts</strong>101 <strong class="flex1" data-i18n="ext_regex_scoped_scripts">Scoped Scripts</strong>
85 <label id="toggle_scoped_regex" class="checkbox flex-container" for="regex_scoped_toggle">102 <label id="toggle_scoped_regex" class="checkbox flex-container" for="regex_scoped_toggle">
@@ -93,6 +110,21 @@
93 </small>110 </small>
94 <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>111 <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>
95 </div>112 </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>
96 </div>128 </div>
97 </div>129 </div>
98</div>130</div>
public/scripts/extensions/regex/engine.js+227 -27
@@ -1,16 +1,228 @@
1import { characters, substituteParams, substituteParamsExtended, this_chid } from '../../../script.js';1import { characters, saveSettingsDebounced, substituteParams, substituteParamsExtended, this_chid } from '../../../script.js';
2import { extension_settings } from '../../extensions.js';2import { extension_settings, writeExtensionField } from '../../extensions.js';
3import { getPresetManager } from '../../preset-manager.js';
3import { regexFromString } from '../../utils.js';4import { regexFromString } from '../../utils.js';
4export {5import { lodash } from '../../../lib.js';
5 regex_placement,6
6 getRegexedString,7/**
7 runRegexScript,8 * @enum {number} Regex scripts types
9 * @readonly
10 */
11export const SCRIPT_TYPES = {
12 GLOBAL: 0,
13 SCOPED: 1,
14 PRESET: 2,
8};15};
916
10/**17/**
18 * Special type for unknown/invalid script types.
19 */
20export 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 */
34const 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 */
42export 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 */
52export 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 */
85export 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 */
110export 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 */
119export 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 */
138export 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 */
159export 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 */
172export 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 */
191export 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 */
209export 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 */
217export function getCurrentPresetName() {
218 return getPresetManager()?.getSelectedPresetName() ?? null;
219}
220
221/**
11 * @enum {number} Where the regex script should be applied222 * @enum {number} Where the regex script should be applied
223 * @readonly
12 */224 */
13const regex_placement = {225export const regex_placement = {
14 /**226 /**
15 * @deprecated MD Display is deprecated. Do not use.227 * @deprecated MD Display is deprecated. Do not use.
16 */228 */
@@ -23,6 +235,10 @@ const regex_placement = {
23 REASONING: 6,235 REASONING: 6,
24};236};
25237
238/**
239 * @enum {number} How to substitute parameters in the find regex
240 * @readonly
241 */
26export const substitute_find_regex = {242export const substitute_find_regex = {
27 NONE: 0,243 NONE: 0,
28 RAW: 1,244 RAW: 1,
@@ -51,22 +267,6 @@ function sanitizeRegexMacro(x) {
51 }) : x;267 }) : x;
52}268}
53269
54function 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
70/**270/**
71 * Parent function to fetch a regexed version of a raw string271 * Parent function to fetch a regexed version of a raw string
72 * @param {string} rawString The raw string to be regexed272 * @param {string} rawString The raw string to be regexed
@@ -75,7 +275,7 @@ function getScopedRegex() {
75 * @returns {string} The regexed string275 * @returns {string} The regexed string
76 * @typedef {{characterOverride?: string, isMarkdown?: boolean, isPrompt?: boolean, isEdit?: boolean, depth?: number }} RegexParams The parameters to use for the regex script276 * @typedef {{characterOverride?: string, isMarkdown?: boolean, isPrompt?: boolean, isEdit?: boolean, depth?: number }} RegexParams The parameters to use for the regex script
77 */277 */
78function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt, isEdit, depth } = {}) {278export function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt, isEdit, depth } = {}) {
79 // WTF have you passed me?279 // WTF have you passed me?
80 if (typeof rawString !== 'string') {280 if (typeof rawString !== 'string') {
81 console.warn('getRegexedString: rawString is not a string. Returning empty string.');281 console.warn('getRegexedString: rawString is not a string. Returning empty string.');
@@ -87,7 +287,7 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown,
87 return finalString;287 return finalString;
88 }288 }
89289
90 const allRegex = [...(extension_settings.regex ?? []), ...(getScopedRegex() ?? [])];290 const allRegex = getRegexScripts({ allowedOnly: true });
91 allRegex.forEach((script) => {291 allRegex.forEach((script) => {
92 if (292 if (
93 // Script applies to Markdown and input is Markdown293 // Script applies to Markdown and input is Markdown
@@ -126,13 +326,13 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown,
126326
127/**327/**
128 * Runs the provided regex script on the given string328 * Runs the provided regex script on the given string
129 * @param {import('./index.js').RegexScript} regexScript The regex script to run329 * @param {RegexScript} regexScript The regex script to run
130 * @param {string} rawString The string to run the regex script on330 * @param {string} rawString The string to run the regex script on
131 * @param {RegexScriptParams} params The parameters to use for the regex script331 * @param {RegexScriptParams} params The parameters to use for the regex script
132 * @returns {string} The new string332 * @returns {string} The new string
133 * @typedef {{characterOverride?: string}} RegexScriptParams The parameters to use for the regex script333 * @typedef {{characterOverride?: string}} RegexScriptParams The parameters to use for the regex script
134 */334 */
135function runRegexScript(regexScript, rawString, { characterOverride } = {}) {335export function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
136 let newString = rawString;336 let newString = rawString;
137 if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) {337 if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) {
138 return newString;338 return newString;
public/scripts/extensions/regex/importTarget.html+6 -0
@@ -15,5 +15,11 @@
15 Scoped Scripts15 Scoped Scripts
16 </span>16 </span>
17 </label>17 </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>
18 </div>24 </div>
19</div>25</div>
public/scripts/extensions/regex/index.js+516 -158
@@ -1,5 +1,5 @@
1import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';1import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
2import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js';2import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
3import { selected_group } from '../../group-chats.js';3import { selected_group } from '../../group-chats.js';
4import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';4import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
5import { SlashCommand } from '../../slash-commands/SlashCommand.js';5import { SlashCommand } from '../../slash-commands/SlashCommand.js';
@@ -7,10 +7,14 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
7import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';7import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
8import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';8import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
9import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';9import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
10import { download, equalsIgnoreCaseAndAccents, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4, escapeHtml } from '../../utils.js';10import { download, equalsIgnoreCaseAndAccents, escapeHtml, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4 } from '../../utils.js';
11import { regex_placement, runRegexScript, substitute_find_regex } from './engine.js';11import { 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';
12import { t } from '../../i18n.js';12import { t } from '../../i18n.js';
13import { accountStorage } from '../../util/AccountStorage.js';13import { accountStorage } from '../../util/AccountStorage.js';
14import { getPresetManager } from '../../preset-manager.js';
15
16// Re-exports for legacy extensions
17export { getRegexScripts };
1418
15const sanitizeFileName = name => name.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase();19const 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, '
30 * @property {boolean} isSelected - Whether the preset is currently selected34 * @property {boolean} isSelected - Whether the preset is currently selected
31 * @property {RegexPresetItem[]} global - The list of global preset items35 * @property {RegexPresetItem[]} global - The list of global preset items
32 * @property {RegexPresetItem[]} scoped - The list of scoped preset items36 * @property {RegexPresetItem[]} scoped - The list of scoped preset items
37 * @property {RegexPresetItem[]} preset - The list of preset preset items
33 */38 */
3439
35/**40/**
36 * @typedef {object} RegexPresetState41 * @typedef {object} RegexPresetState
37 * @property {string[]} global - List of enabled global regex script IDs42 * @property {string[]} global - List of enabled global regex script IDs
38 * @property {string[]} scoped - List of enabled scoped regex script IDs43 * @property {string[]} scoped - List of enabled scoped regex script IDs
44 * @property {string[]} preset - List of enabled preset regex script IDs
39 */45 */
4046
41class RegexPresetManager {47class RegexPresetManager {
@@ -65,12 +71,14 @@ class RegexPresetManager {
65 * @returns {RegexPresetState} The current state object71 * @returns {RegexPresetState} The current state object
66 */72 */
67 captureCurrentState() {73 captureCurrentState() {
68 const globalScripts = this.regexListToPresetItems(extension_settings.regex) || [];74 const globalScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.GLOBAL));
69 const scopedScripts = this.regexListToPresetItems(characters[this_chid]?.data?.extensions?.regex_scripts) || [];75 const scopedScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.SCOPED));
76 const presetScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET));
7077
71 return {78 return {
72 global: globalScripts.map(item => item.id).sort(),79 global: globalScripts.map(item => item.id).sort(),
73 scoped: scopedScripts.map(item => item.id).sort(),80 scoped: scopedScripts.map(item => item.id).sort(),
81 preset: presetScripts.map(item => item.id).sort(),
74 };82 };
75 }83 }
7684
@@ -87,13 +95,16 @@ class RegexPresetManager {
87 const global2 = state2.global || [];95 const global2 = state2.global || [];
88 const scoped1 = state1.scoped || [];96 const scoped1 = state1.scoped || [];
89 const scoped2 = state2.scoped || [];97 const scoped2 = state2.scoped || [];
98 const preset1 = state1.preset || [];
99 const preset2 = state2.preset || [];
90100
91 if (global1.length !== global2.length || scoped1.length !== scoped2.length) {101 if (global1.length !== global2.length || scoped1.length !== scoped2.length) {
92 return true;102 return true;
93 }103 }
94104
95 return !global1.every(id => global2.includes(id)) ||105 return !global1.every(id => global2.includes(id)) ||
96 !scoped1.every(id => scoped2.includes(id));106 !scoped1.every(id => scoped2.includes(id)) ||
107 !preset1.every(id => preset2.includes(id));
97 }108 }
98109
99 /**110 /**
@@ -362,17 +373,18 @@ class RegexPresetManager {
362 return;373 return;
363 }374 }
364375
365 // Apply to both global and scoped lists376 // Apply preset to all lists
366 await this.applyPresetList({377 for (const scriptType of Object.values(SCRIPT_TYPES)) {
367 presetList: preset.global,
368 targetList: extension_settings.regex,
369 saveFunction: () => saveSettingsDebounced(),
370 });
371 await this.applyPresetList({378 await this.applyPresetList({
372 presetList: preset.scoped,379 presetList: {
373 targetList: characters[this_chid]?.data?.extensions?.regex_scripts,380 [SCRIPT_TYPES.GLOBAL]: preset.global,
374 saveFunction: (scripts) => writeExtensionField(this_chid, 'regex_scripts', scripts),381 [SCRIPT_TYPES.SCOPED]: preset.scoped,
382 [SCRIPT_TYPES.PRESET]: preset.preset,
383 }[scriptType],
384 targetList: getScriptsByType(scriptType),
385 saveFunction: scripts => saveScriptsByType(scripts, scriptType),
375 });386 });
387 }
376388
377 // Render the changes to the UI389 // Render the changes to the UI
378 await loadRegexScripts();390 await loadRegexScripts();
@@ -418,8 +430,9 @@ class RegexPresetManager {
418 id: id,430 id: id,
419 name: name,431 name: name,
420 isSelected: false,432 isSelected: false,
421 global: this.regexListToPresetItems(extension_settings.regex),433 global: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.GLOBAL)),
422 scoped: this.regexListToPresetItems(characters[this_chid]?.data?.extensions?.regex_scripts),434 scoped: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.SCOPED)),
435 preset: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET)),
423 };436 };
424437
425 if (isUpdate) {438 if (isUpdate) {
@@ -465,15 +478,6 @@ class RegexPresetManager {
465const presetManager = new RegexPresetManager();478const presetManager = new RegexPresetManager();
466479
467/**480/**
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 */
472export function getRegexScripts() {
473 return [...(extension_settings.regex ?? []), ...(characters[this_chid]?.data?.extensions?.regex_scripts ?? [])];
474}
475
476/**
477 * Toggle the icon for the "select all" checkbox in the regex settings.481 * Toggle the icon for the "select all" checkbox in the regex settings.
478 * - Use `fa-check-double` when the checkbox is unchecked (indicating all scripts are not selected).482 * - Use `fa-check-double` when the checkbox is unchecked (indicating all scripts are not selected).
479 * - Use `fa-minus` when the checkbox is checked (indicating all scripts are selected).483 * - Use `fa-minus` when the checkbox is checked (indicating all scripts are selected).
@@ -486,15 +490,28 @@ function setToggleAllIcon(allAreChecked) {
486}490}
487491
488/**492/**
493 * Sets the visibility of the bulk move buttons based on selected scripts.
494 */
495function 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/**
489 * Saves a regex script to the extension settings or character data.505 * Saves a regex script to the extension settings or character data.
490 * @param {import('../../char-data.js').RegexScriptData} regexScript506 * @param {import('../../char-data.js').RegexScriptData} regexScript
491 * @param {number} existingScriptIndex Index of the existing script507 * @param {number} existingScriptIndex Index of the existing script
492 * @param {boolean} isScoped Is the script scoped to a character?508 * @param {SCRIPT_TYPES} scriptType Type of the script
509 * @param {boolean} [saveSettings=true] Whether to save the settings immediately
493 * @returns {Promise<void>}510 * @returns {Promise<void>}
494 */511 */
495async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {512async function saveRegexScript(regexScript, existingScriptIndex, scriptType, saveSettings = true) {
496 // If not editing513 // If not editing
497 const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];514 const array = getScriptsByType(scriptType);
498515
499 // Assign a UUID if it doesn't exist516 // Assign a UUID if it doesn't exist
500 if (!regexScript.id) {517 if (!regexScript.id) {
@@ -523,23 +540,26 @@ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
523 array.push(regexScript);540 array.push(regexScript);
524 }541 }
525542
526 if (isScoped) {543 if (scriptType === SCRIPT_TYPES.SCOPED) {
527 await writeExtensionField(this_chid, 'regex_scripts', array);544 await saveScriptsByType(array, SCRIPT_TYPES.SCOPED);
528545 allowScopedScripts(characters?.[this_chid]);
529 // Add the character to the allowed list
530 if (!extension_settings.character_allowed_regex.includes(characters[this_chid].avatar)) {
531 extension_settings.character_allowed_regex.push(characters[this_chid].avatar);
532 }546 }
547
548 if (scriptType === SCRIPT_TYPES.PRESET) {
549 await saveScriptsByType(array, SCRIPT_TYPES.PRESET);
550 allowPresetScripts(getCurrentPresetAPI(), getCurrentPresetName());
533 }551 }
534552
553 if (saveSettings) {
535 saveSettingsDebounced();554 saveSettingsDebounced();
536 await loadRegexScripts();555 await loadRegexScripts();
537556
538 // Reload the current chat to undo previous markdown557 // Reload the current chat to undo previous markdown
539 const currentChatId = getCurrentChatId();558 const currentChatId = getCurrentChatId();
540 if (currentChatId !== undefined && currentChatId !== null) {559 if (currentChatId) {
541 await reloadCurrentChat();560 await reloadCurrentChat();
542 }561 }
562 }
543563
544 const debuggerPopup = $('#regex_debugger_popup');564 const debuggerPopup = $('#regex_debugger_popup');
545 if (debuggerPopup.length) {565 if (debuggerPopup.length) {
@@ -547,25 +567,67 @@ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
547 }567 }
548}568}
549569
550async 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 */
577async function deleteRegexScript(id, scriptType, saveSettings = true) {
578 const array = getScriptsByType(scriptType);
552579
553 const existingScriptIndex = array.findIndex((script) => script.id === id);580 const existingScriptIndex = array.findIndex(script => script.id === id);
554 if (existingScriptIndex !== -1) {581 if (existingScriptIndex !== -1) {
555 array.splice(existingScriptIndex, 1);582 array.splice(existingScriptIndex, 1);
556583
557 if (isScoped) {584 switch (scriptType) {
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;
559 }596 }
560597 if (saveSettings) {
561 saveSettingsDebounced();598 saveSettingsDebounced();
562 await loadRegexScripts();599 await loadRegexScripts();
563 }600 }
564 }601 }
602}
603
604/**
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 */
612async 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);
619 }
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);
625}
565626
566async function loadRegexScripts() {627async function loadRegexScripts() {
567 $('#saved_regex_scripts').empty();628 $('#saved_regex_scripts').empty();
568 $('#saved_scoped_scripts').empty();629 $('#saved_scoped_scripts').empty();
630 $('#saved_preset_scripts').empty();
569 setToggleAllIcon(false);631 setToggleAllIcon(false);
570632
571 const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate'));633 const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate'));
@@ -574,20 +636,20 @@ async function loadRegexScripts() {
574 * Renders a script to the UI.636 * Renders a script to the UI.
575 * @param {string} container Container to render the script to637 * @param {string} container Container to render the script to
576 * @param {import('../../char-data.js').RegexScriptData} script Script data638 * @param {import('../../char-data.js').RegexScriptData} script Script data
577 * @param {boolean} isScoped Script is scoped to a character639 * @param {SCRIPT_TYPES} scriptType Type of the script
578 * @param {number} index Index of the script in the array640 * @param {number} index Index of the script in the array
579 */641 */
580 function renderScript(container, script, isScoped, index) {642 function renderScript(container, script, scriptType, index) {
581 // Have to clone here643 // Have to clone here
582 const scriptHtml = scriptTemplate.clone();644 const scriptHtml = scriptTemplate.clone();
583 const save = () => saveRegexScript(script, index, isScoped);645 const save = () => saveRegexScript(script, index, scriptType);
584646
585 if (!script.id) {647 if (!script.id) {
586 script.id = uuidv4();648 script.id = uuidv4();
587 }649 }
588650
589 scriptHtml.attr('id', script.id);651 scriptHtml.attr('id', script.id);
590 scriptHtml.find('.regex_script_name').text(script.scriptName);652 scriptHtml.find('.regex_script_name').text(script.scriptName).attr('title', script.scriptName);
591 scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false)653 scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false)
592 .on('input', async function () {654 .on('input', async function () {
593 script.disabled = !!$(this).prop('checked');655 script.disabled = !!$(this).prop('checked');
@@ -600,7 +662,7 @@ async function loadRegexScripts() {
600 scriptHtml.find('.disable_regex').prop('checked', false).trigger('input');662 scriptHtml.find('.disable_regex').prop('checked', false).trigger('input');
601 });663 });
602 scriptHtml.find('.edit_existing_regex').on('click', async function () {664 scriptHtml.find('.edit_existing_regex').on('click', async function () {
603 await onRegexEditorOpenClick(scriptHtml.attr('id'), isScoped);665 await onRegexEditorOpenClick(scriptHtml.attr('id'), scriptType);
604 });666 });
605 scriptHtml.find('.move_to_global').on('click', async function () {667 scriptHtml.find('.move_to_global').on('click', async function () {
606 const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to global?`, POPUP_TYPE.CONFIRM);668 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() {
608 if (!confirm) {670 if (!confirm) {
609 return;671 return;
610 }672 }
611673 await moveRegexScript(script, SCRIPT_TYPES.GLOBAL, scriptType);
612 await deleteRegexScript({ id: script.id, isScoped: true });
613 await saveRegexScript(script, -1, false);
614 });674 });
615 scriptHtml.find('.move_to_scoped').on('click', async function () {675 scriptHtml.find('.move_to_scoped').on('click', async function () {
616 if (this_chid === undefined) {676 if (this_chid === undefined) {
617 toastr.error(t`No character selected.`);677 toastr.error(t`No character selected.`);
618 return;678 return;
619 }679 }
620
621 if (selected_group) {680 if (selected_group) {
622 toastr.error(t`Cannot edit scoped scripts in group chats.`);681 toastr.error(t`Cannot edit scoped scripts in group chats.`);
623 return;682 return;
624 }683 }
625
626 const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to scoped?`, POPUP_TYPE.CONFIRM);684 const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to scoped?`, POPUP_TYPE.CONFIRM);
627
628 if (!confirm) {685 if (!confirm) {
629 return;686 return;
630 }687 }
631688 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);
634 });699 });
635 scriptHtml.find('.export_regex').on('click', async function () {700 scriptHtml.find('.export_regex').on('click', async function () {
636 const fileName = `regex-${sanitizeFileName(script.scriptName)}.json`;701 const fileName = `regex-${sanitizeFileName(script.scriptName)}.json`;
@@ -639,39 +704,65 @@ async function loadRegexScripts() {
639 });704 });
640 scriptHtml.find('.delete_regex').on('click', async function () {705 scriptHtml.find('.delete_regex').on('click', async function () {
641 const confirm = await callGenericPopup(t`Are you sure you want to delete this regex script?`, POPUP_TYPE.CONFIRM);706 const confirm = await callGenericPopup(t`Are you sure you want to delete this regex script?`, POPUP_TYPE.CONFIRM);
642
643 if (!confirm) {707 if (!confirm) {
644 return;708 return;
645 }709 }
646710 await deleteRegexScript(script.id, scriptType);
647 await deleteRegexScript({ id: script.id, isScoped });
648 await reloadCurrentChat();711 await reloadCurrentChat();
649 });712 });
650 scriptHtml.find('.regex_bulk_checkbox').on('change', function () {713 scriptHtml.find('.regex_bulk_checkbox').on('change', function () {
714 setMoveButtonsVisibility();
651 const checkboxes = $('#regex_container .regex_bulk_checkbox');715 const checkboxes = $('#regex_container .regex_bulk_checkbox');
652 const allAreChecked = checkboxes.length === checkboxes.filter(':checked').length;716 const allAreChecked = checkboxes.length === checkboxes.filter(':checked').length;
653 setToggleAllIcon(allAreChecked);717 setToggleAllIcon(allAreChecked);
654 });718 });
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
656 $(container).append(scriptHtml);744 $(container).append(scriptHtml);
657 }745 }
658746
659 extension_settings?.regex?.forEach((script, index) => renderScript('#saved_regex_scripts', script, false, index));747 getScriptsByType(SCRIPT_TYPES.GLOBAL).forEach((script, index) => renderScript('#saved_regex_scripts', script, SCRIPT_TYPES.GLOBAL, index));
660 characters[this_chid]?.data?.extensions?.regex_scripts?.forEach((script, index) => renderScript('#saved_scoped_scripts', script, true, index));748 getScriptsByType(SCRIPT_TYPES.SCOPED).forEach((script, index) => renderScript('#saved_scoped_scripts', script, SCRIPT_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]));
663 $('#regex_scoped_toggle').prop('checked', isAllowed);752 $('#regex_preset_toggle').prop('checked', isPresetScriptsAllowed(getCurrentPresetAPI(), getCurrentPresetName()));
753
754 setMoveButtonsVisibility();
664}755}
665756
666/**757/**
667 * Opens the regex editor.758 * Opens the regex editor.
668 * @param {string|boolean} existingId Existing ID759 * @param {string|boolean} existingId Existing ID
669 * @param {boolean} isScoped Is the script scoped to a character?760 * @param {SCRIPT_TYPES} scriptType Type of the script
670 * @returns {Promise<void>}761 * @returns {Promise<void>}
671 */762 */
672async function onRegexEditorOpenClick(existingId, isScoped) {763async function onRegexEditorOpenClick(existingId, scriptType) {
673 const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor'));764 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
676 // If an ID exists, fill in all the values767 // If an ID exists, fill in all the values
677 let existingScriptIndex = -1;768 let existingScriptIndex = -1;
@@ -776,7 +867,7 @@ async function onRegexEditorOpenClick(existingId, isScoped) {
776 maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())),867 maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())),
777 };868 };
778869
779 saveRegexScript(newRegexScript, existingScriptIndex, isScoped);870 saveRegexScript(newRegexScript, existingScriptIndex, scriptType);
780 }871 }
781}872}
782873
@@ -969,28 +1060,35 @@ function populateDebuggerRuleList(container) {
9691060
970 const allScripts = getRegexScripts();1061 const allScripts = getRegexScripts();
971 if (!allScripts || allScripts.length === 0) {1062 if (!allScripts || allScripts.length === 0) {
972 rulesContainer.append('<div class="regex-debugger-no-rules">No regex rules found.</div>');1063 rulesContainer.append('<div class="regex-debugger-no-rules">' + t`No regex rules found.` + '</div>');
973 return;1064 return;
974 }1065 }
9751066
976 const globalScriptIds = new Set((extension_settings.regex ?? []).map(s => s.id));1067 const globalScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.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));
977 const globalScripts = [];1070 const globalScripts = [];
978 const scopedScripts = [];1071 const scopedScripts = [];
1072 const presetScripts = [];
9791073
980 allScripts.forEach(script => {1074 allScripts.forEach(script => {
981 const scriptCopy = structuredClone(script); // Use structuredClone for deep copy1075 const scriptCopy = structuredClone(script); // Use structuredClone for deep copy
982 if (globalScriptIds.has(script.id)) {1076 if (globalScriptIds.has(script.id)) {
983 // @ts-ignore1077 // @ts-ignore
984 scriptCopy.isScoped = false;1078 scriptCopy.type = SCRIPT_TYPES.GLOBAL;
985 globalScripts.push(scriptCopy);1079 globalScripts.push(scriptCopy);
986 } else {1080 } else if (scopedScriptIds.has(script.id)) {
987 // @ts-ignore1081 // @ts-ignore
988 scriptCopy.isScoped = true;1082 scriptCopy.type = SCRIPT_TYPES.SCOPED;
989 scopedScripts.push(scriptCopy);1083 scopedScripts.push(scriptCopy);
1084 } else if (presetScriptIds.has(script.id)) {
1085 // @ts-ignore
1086 scriptCopy.type = SCRIPT_TYPES.PRESET;
1087 presetScripts.push(scriptCopy);
990 }1088 }
991 });1089 });
9921090
993 container.data('allScripts', [...globalScripts, ...scopedScripts]);1091 container.data('allScripts', [...globalScripts, ...scopedScripts, ...presetScripts]);
9941092
995 const renderRule = (script) => {1093 const renderRule = (script) => {
996 if (!script.id) script.id = uuidv4();1094 if (!script.id) script.id = uuidv4();
@@ -1002,10 +1100,18 @@ function populateDebuggerRuleList(container) {
1002 ruleElement.find('.rule-name').text(script.scriptName);1100 ruleElement.find('.rule-name').text(script.scriptName);
1003 ruleElement.find('.rule-regex').text(script.findRegex);1101 ruleElement.find('.rule-regex').text(script.findRegex);
1004 // @ts-ignore1102 // @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 );
1006 ruleElement.find('.rule-enabled').prop('checked', !script.disabled);1112 ruleElement.find('.rule-enabled').prop('checked', !script.disabled);
1007 // @ts-ignore1113 // @ts-ignore
1008 ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.isScoped));1114 ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.type));
10091115
1010 ruleElement.on('click', function (event) {1116 ruleElement.on('click', function (event) {
1011 if ($(event.target).is('input, .menu_button, .menu_button i')) {1117 if ($(event.target).is('input, .menu_button, .menu_button i')) {
@@ -1035,18 +1141,25 @@ function populateDebuggerRuleList(container) {
1035 };1141 };
10361142
1037 if (globalScripts.length > 0) {1143 if (globalScripts.length > 0) {
1038 rulesContainer.append('<div class="list-header regex-debugger-list-header">Global Rules</div>');1144 rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Global Rules` + '</div>');
1039 const globalList = $('<ul id="regex_debugger_rules_global" class="sortable-list"></ul>');1145 const globalList = $('<ul id="regex_debugger_rules_global" class="sortable-list"></ul>');
1040 globalScripts.forEach(script => globalList.append(renderRule(script)));1146 globalScripts.forEach(script => globalList.append(renderRule(script)));
1041 rulesContainer.append(globalList);1147 rulesContainer.append(globalList);
1042 }1148 }
10431149
1044 if (scopedScripts.length > 0) {1150 if (scopedScripts.length > 0) {
1045 rulesContainer.append('<div class="list-header regex-debugger-list-header">Scoped Rules</div>');1151 rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Scoped Rules` + '</div>');
1046 const scopedList = $('<ul id="regex_debugger_rules_scoped" class="sortable-list"></ul>');1152 const scopedList = $('<ul id="regex_debugger_rules_scoped" class="sortable-list"></ul>');
1047 scopedScripts.forEach(script => scopedList.append(renderRule(script)));1153 scopedScripts.forEach(script => scopedList.append(renderRule(script)));
1048 rulesContainer.append(scopedList);1154 rulesContainer.append(scopedList);
1049 }1155 }
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 }
1050}1163}
10511164
1052/**1165/**
@@ -1065,12 +1178,15 @@ async function onRegexDebuggerOpenClick() {
1065 debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();1178 debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
1066 // @ts-ignore1179 // @ts-ignore
1067 debuggerHtml.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();1180 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
1069 debuggerHtml.find('#regex_debugger_run_test').on('click', function () {1184 debuggerHtml.find('#regex_debugger_run_test').on('click', function () {
1070 const allScripts = debuggerHtml.data('allScripts');1185 const allScripts = debuggerHtml.data('allScripts');
1071 const orderedRuleIds = [1186 const orderedRuleIds = [
1072 ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),1187 ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
1073 ...$('#regex_debugger_rules_scoped').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),1188 ...$('#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(),
1074 ];1190 ];
10751191
1076 const rawInput = String($('#regex_debugger_raw_input').val());1192 const rawInput = String($('#regex_debugger_raw_input').val());
@@ -1106,9 +1222,9 @@ async function onRegexDebuggerOpenClick() {
1106 // Set the ID on the TOP-LEVEL element that is being appended.1222 // Set the ID on the TOP-LEVEL element that is being appended.
1107 stepElement.find('>:first-child').attr('id', `step-result-${script.id}`);1223 stepElement.find('>:first-child').attr('id', `step-result-${script.id}`);
1108 const stepHeader = stepElement.find('.step-header');1224 const stepHeader = stepElement.find('.step-header');
1109 stepHeader.find('strong').text(`After: ${script.scriptName}`);1225 stepHeader.find('strong').text(t`After:` + ` ${script.scriptName}`);
11101226
1111 const metricsHtml = `<span class="step-metrics">Captured: ${result.charsCaptured}, Added: +${result.charsAdded}, Removed: -${result.charsRemoved}</span>`;1227 const metricsHtml = '<span class="step-metrics">' + t`Captured:` + ` ${result.charsCaptured}, ` + t`Added:` + ` +${result.charsAdded}, ` + t`Removed:` + ` -${result.charsRemoved}</span>`;
1112 stepHeader.append(metricsHtml);1228 stepHeader.append(metricsHtml);
11131229
1114 if (displayMode === 'highlight') {1230 if (displayMode === 'highlight') {
@@ -1128,7 +1244,7 @@ async function onRegexDebuggerOpenClick() {
11281244
1129 const summaryHtml = `1245 const summaryHtml = `
1130 <div id="regex_debugger_final_summary" class="regex-debugger-summary">1246 <div id="regex_debugger_final_summary" class="regex-debugger-summary">
1131 <strong>Total Captured:</strong> ${totalCharsCaptured} | <strong>Total Added:</strong> +${totalCharsAdded} | <strong>Total Removed:</strong> -${totalCharsRemoved}1247 <strong>` + t`Total Captured:` + `</strong> ${totalCharsCaptured} | <strong>` + t`Total Added:` + `</strong> +${totalCharsAdded} | <strong>` + t`Total Removed:` + `</strong> -${totalCharsRemoved}
1132 </div>1248 </div>
1133 `;1249 `;
1134 finalOutput.before(summaryHtml);1250 finalOutput.before(summaryHtml);
@@ -1148,11 +1264,13 @@ async function onRegexDebuggerOpenClick() {
1148 const allKnownScripts = getRegexScripts();1264 const allKnownScripts = getRegexScripts();
1149 const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);1265 const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
1150 const newScopedScripts = $('#regex_debugger_rules_scoped').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);1266 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
1152 extension_settings.regex = newGlobalScripts;1269 extension_settings.regex = newGlobalScripts;
1153 if (this_chid !== undefined) {1270 if (this_chid !== undefined) {
1154 await writeExtensionField(this_chid, 'regex_scripts', newScopedScripts);1271 await saveScriptsByType(newScopedScripts, SCRIPT_TYPES.SCOPED);
1155 }1272 }
1273 await saveScriptsByType(newPresetScripts, SCRIPT_TYPES.PRESET);
11561274
1157 saveSettingsDebounced();1275 saveSettingsDebounced();
1158 await loadRegexScripts();1276 await loadRegexScripts();
@@ -1164,6 +1282,8 @@ async function onRegexDebuggerOpenClick() {
1164 currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();1282 currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
1165 // @ts-ignore1283 // @ts-ignore
1166 currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();1284 currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
1285 // @ts-ignore
1286 currentPopupContent.find('#regex_debugger_rules_preset').sortable({ delay: getSortableDelay() }).disableSelection();
1167 });1287 });
11681288
1169 debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () {1289 debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () {
@@ -1204,13 +1324,13 @@ async function onRegexDebuggerOpenClick() {
1204 });1324 });
12051325
1206 popupContainer.append(navPanel).append(contentPanel);1326 popupContainer.append(navPanel).append(contentPanel);
1207 callGenericPopup(popupContainer, POPUP_TYPE.TEXT, 'Step-by-step Transformation', { wide: true, allowVerticalScrolling: false });1327 callGenericPopup(popupContainer, POPUP_TYPE.TEXT, t`Step-by-step Transformation`, { wide: true, allowVerticalScrolling: false });
1208 });1328 });
12091329
1210 debuggerHtml.find('#regex_debugger_expand_final').on('click', function () {1330 debuggerHtml.find('#regex_debugger_expand_final').on('click', function () {
1211 const content = $('#regex_debugger_final_output').html();1331 const content = $('#regex_debugger_final_output').html();
1212 const popupContent = $('<div class="regex-popup-content"></div>').html(content);1332 const popupContent = $('<div class="regex-popup-content"></div>').html(content);
1213 callGenericPopup(popupContent, POPUP_TYPE.TEXT, 'Final Output', { wide: true, large: true, allowVerticalScrolling: true });1333 callGenericPopup(popupContent, POPUP_TYPE.TEXT, t`Final Output`, { wide: true, large: true, allowVerticalScrolling: true });
1214 });1334 });
12151335
1216 await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true });1336 await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true });
@@ -1289,11 +1409,6 @@ function migrateSettings() {
1289 }1409 }
1290 });1410 });
12911411
1292 if (!extension_settings.character_allowed_regex) {
1293 extension_settings.character_allowed_regex = [];
1294 performSave = true;
1295 }
1296
1297 if (performSave) {1412 if (performSave) {
1298 saveSettingsDebounced();1413 saveSettingsDebounced();
1299 }1414 }
@@ -1364,10 +1479,10 @@ async function toggleRegexCallback(args, scriptName) {
1364 break;1479 break;
1365 }1480 }
13661481
1367 const isScoped = characters[this_chid]?.data?.extensions?.regex_scripts?.some(s => s.id === script.id);1482 const scriptType = getScriptType(script);
1368 const index = isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts?.indexOf(script) : scripts.indexOf(script);1483 const index = getScriptsByType(scriptType).indexOf(script);
13691484
1370 await saveRegexScript(script, index, isScoped);1485 await saveRegexScript(script, index, scriptType);
1371 if (script.disabled) {1486 if (script.disabled) {
1372 !quiet && toastr.success(t`Regex script '${scriptName}' has been disabled.`);1487 !quiet && toastr.success(t`Regex script '${scriptName}' has been disabled.`);
1373 } else {1488 } else {
@@ -1379,10 +1494,10 @@ async function toggleRegexCallback(args, scriptName) {
13791494
1380/**1495/**
1381 * Performs the import of the regex object.1496 * Performs the import of the regex object.
1382 * @param {Object} regexScript Input object1497 * @param {RegexScript} regexScript Input object
1383 * @param {boolean} isScoped Is the script scoped to a character?1498 * @param {SCRIPT_TYPES} scriptType The type of script to import as
1384 */1499 */
1385async function onRegexImportObjectChange(regexScript, isScoped) {1500async function onRegexImportObjectChange(regexScript, scriptType) {
1386 try {1501 try {
1387 if (!regexScript.scriptName) {1502 if (!regexScript.scriptName) {
1388 throw new Error('No script name provided.');1503 throw new Error('No script name provided.');
@@ -1391,11 +1506,21 @@ async function onRegexImportObjectChange(regexScript, isScoped) {
1391 // Assign a new UUID1506 // Assign a new UUID
1392 regexScript.id = uuidv4();1507 regexScript.id = uuidv4();
13931508
1394 const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];1509 const array = getScriptsByType(scriptType);
1395 array.push(regexScript);1510 array.push(regexScript);
13961511
1397 if (isScoped) {1512 switch (scriptType) {
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;
1399 }1524 }
14001525
1401 saveSettingsDebounced();1526 saveSettingsDebounced();
@@ -1411,9 +1536,9 @@ async function onRegexImportObjectChange(regexScript, isScoped) {
1411/**1536/**
1412 * Performs the import of the regex file.1537 * Performs the import of the regex file.
1413 * @param {File} file Input file1538 * @param {File} file Input file
1414 * @param {boolean} isScoped Is the script scoped to a character?1539 * @param {SCRIPT_TYPES} scriptType The type of script to import as
1415 */1540 */
1416async function onRegexImportFileChange(file, isScoped) {1541async function onRegexImportFileChange(file, scriptType) {
1417 if (!file) {1542 if (!file) {
1418 toastr.error('No file provided.');1543 toastr.error('No file provided.');
1419 return;1544 return;
@@ -1423,10 +1548,10 @@ async function onRegexImportFileChange(file, isScoped) {
1423 const regexScripts = JSON.parse(await getFileText(file));1548 const regexScripts = JSON.parse(await getFileText(file));
1424 if (Array.isArray(regexScripts)) {1549 if (Array.isArray(regexScripts)) {
1425 for (const regexScript of regexScripts) {1550 for (const regexScript of regexScripts) {
1426 await onRegexImportObjectChange(regexScript, isScoped);1551 await onRegexImportObjectChange(regexScript, scriptType);
1427 }1552 }
1428 } else {1553 } else {
1429 await onRegexImportObjectChange(regexScripts, isScoped);1554 await onRegexImportObjectChange(regexScripts, scriptType);
1430 }1555 }
1431 } catch (error) {1556 } catch (error) {
1432 console.log(error);1557 console.log(error);
@@ -1435,45 +1560,150 @@ async function onRegexImportFileChange(file, isScoped) {
1435 }1560 }
1436}1561}
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 */
1568function 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
1578function 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
1438function purgeEmbeddedRegexScripts({ character }) {1587function purgeEmbeddedRegexScripts({ character }) {
1439 const avatar = character?.avatar;1588 const avatar = character?.avatar;
14401589 if (!avatar) {
1441 if (avatar && extension_settings.character_allowed_regex?.includes(avatar)) {1590 return;
1442 const index = extension_settings.character_allowed_regex.indexOf(avatar);
1443 if (index !== -1) {
1444 extension_settings.character_allowed_regex.splice(index, 1);
1445 saveSettingsDebounced();
1446 }1591 }
1592 const checkKey = `AlertRegex_${avatar}`;
1593 if (accountStorage.getItem(checkKey)) {
1594 accountStorage.removeItem(checkKey);
1447 }1595 }
1596 disallowScopedScripts(characters?.[this_chid]);
1448}1597}
14491598
1450async function checkEmbeddedRegexScripts() {1599function purgePresetEmbeddedRegexScripts({ apiId, name }) {
1600 const checkKey = `AlertRegex_${apiId}_${name}`;
1601 if (accountStorage.getItem(checkKey)) {
1602 accountStorage.removeItem(checkKey);
1603 }
1604 disallowPresetScripts(apiId, name);
1605}
1606
1607async function checkCharEmbeddedRegexScripts() {
1451 const chid = this_chid;1608 const chid = this_chid;
14521609
1453 if (chid !== undefined && !selected_group) {1610 if (chid !== undefined && !selected_group) {
1454 const avatar = characters[chid]?.avatar;1611 const character = characters[chid];
1455 const scripts = characters[chid]?.data?.extensions?.regex_scripts;1612 const scripts = getScriptsByType(SCRIPT_TYPES.SCOPED);
14561613
1457 if (Array.isArray(scripts) && scripts.length > 0) {1614 if (Array.isArray(scripts) && scripts.length > 0) {
1458 if (avatar && !extension_settings.character_allowed_regex.includes(avatar)) {1615 if (!isScopedScriptsAllowed(character)) {
1459 const checkKey = `AlertRegex_${characters[chid].avatar}`;1616 const checkKey = `AlertRegex_${character.avatar}`;
1460
1461 if (!accountStorage.getItem(checkKey)) {1617 if (!accountStorage.getItem(checkKey)) {
1462 accountStorage.setItem(checkKey, 'true');1618 accountStorage.setItem(checkKey, 'true');
1463 const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {});1619 const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {});
1464 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { okButton: 'Yes' });1620 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '');
14651621
1466 if (result) {1622 if (result) {
1467 extension_settings.character_allowed_regex.push(avatar);1623 allowScopedScripts(character);
1468 await reloadCurrentChat();1624 await reloadCurrentChat();
1469 saveSettingsDebounced();
1470 }1625 }
1471 }1626 }
1472 }1627 }
1473 }1628 }
1474 }1629 }
14751630
1476 loadRegexScripts();1631 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 */
1638function 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
1649async 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
1678async 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
1695function 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 }
1477}1707}
14781708
1479// Workaround for loading in sequence with other extensions1709// Workaround for loading in sequence with other extensions
@@ -1497,7 +1727,7 @@ jQuery(async () => {
1497 const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown'));1727 const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown'));
1498 $('#regex_container').append(settingsHtml);1728 $('#regex_container').append(settingsHtml);
1499 $('#open_regex_editor').on('click', function () {1729 $('#open_regex_editor').on('click', function () {
1500 onRegexEditorOpenClick(false, false);1730 onRegexEditorOpenClick(false, SCRIPT_TYPES.GLOBAL);
1501 });1731 });
1502 $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick);1732 $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick);
1503 $('#open_scoped_editor').on('click', function () {1733 $('#open_scoped_editor').on('click', function () {
@@ -1511,19 +1741,23 @@ jQuery(async () => {
1511 return;1741 return;
1512 }1742 }
15131743
1514 onRegexEditorOpenClick(false, true);1744 onRegexEditorOpenClick(false, SCRIPT_TYPES.SCOPED);
1745 });
1746 $('#open_preset_editor').on('click', function () {
1747 onRegexEditorOpenClick(false, SCRIPT_TYPES.PRESET);
1515 });1748 });
1516 $('#import_regex_file').on('change', async function () {1749 $('#import_regex_file').on('change', async function () {
1517 let target = 'global';1750 let target = SCRIPT_TYPES.GLOBAL;
1518 const template = $(await renderExtensionTemplateAsync('regex', 'importTarget'));1751 const template = $(await renderExtensionTemplateAsync('regex', 'importTarget'));
1519 template.find('#regex_import_target_global').on('input', () => target = 'global');1752 template.find('#regex_import_target_global').on('input', () => (target = SCRIPT_TYPES.GLOBAL));
1520 template.find('#regex_import_target_scoped').on('input', () => target = 'scoped');1753 template.find('#regex_import_target_scoped').on('input', () => (target = SCRIPT_TYPES.SCOPED));
1754 template.find('#regex_import_target_preset').on('input', () => (target = SCRIPT_TYPES.PRESET));
15211755
1522 await callGenericPopup(template, POPUP_TYPE.TEXT);1756 await callGenericPopup(template, POPUP_TYPE.TEXT);
15231757
1524 const inputElement = this instanceof HTMLInputElement && this;1758 const inputElement = this instanceof HTMLInputElement && this;
1525 for (const file of inputElement.files) {1759 for (const file of inputElement.files) {
1526 await onRegexImportFileChange(file, target === 'scoped');1760 await onRegexImportFileChange(file, target);
1527 }1761 }
1528 inputElement.value = '';1762 inputElement.value = '';
1529 });1763 });
@@ -1531,13 +1765,6 @@ jQuery(async () => {
1531 $('#import_regex_file').trigger('click');1765 $('#import_regex_file').trigger('click');
1532 });1766 });
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
1541 $('#bulk_select_all_toggle').on('click', async function () {1768 $('#bulk_select_all_toggle').on('click', async function () {
1542 const checkboxes = $('#regex_container .regex_bulk_checkbox');1769 const checkboxes = $('#regex_container .regex_bulk_checkbox');
1543 if (checkboxes.length === 0) {1770 if (checkboxes.length === 0) {
@@ -1549,32 +1776,98 @@ jQuery(async () => {
15491776
1550 checkboxes.prop('checked', newState);1777 checkboxes.prop('checked', newState);
1551 setToggleAllIcon(newState);1778 setToggleAllIcon(newState);
1779 setMoveButtonsVisibility();
1552 });1780 });
15531781
1554 $('#bulk_enable_regex').on('click', async function () {1782 $('#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);
1556 if (scripts.length === 0) {1797 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 );
1558 return;1802 return;
1559 }1803 }
1804 const scriptTypesToSave = new Set();
1560 for (const script of scripts) {1805 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);
1562 }1813 }
1814
1563 saveSettingsDebounced();1815 saveSettingsDebounced();
1564 await loadRegexScripts();1816 await loadRegexScripts();
1565 });
15661817
1567 $('#bulk_disable_regex').on('click', async function () {1818 // Reload the current chat to undo previous markdown
1568 const scripts = getSelectedScripts().filter(script => !script.disabled);1819 const currentChatId = getCurrentChatId();
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();
1569 if (scripts.length === 0) {1831 if (scripts.length === 0) {
1570 toastr.warning(t`No regex scripts selected for disabling.`);1832 toastr.warning(t`No regex scripts selected for moving.`);
1571 return;1833 return;
1572 }1834 }
1573 for (const script of scripts) {1835 for (const script of scripts) {
1574 script.disabled = true;1836 await moveRegexScript(script, toType, getScriptType(script), false);
1575 }1837 }
1838
1576 saveSettingsDebounced();1839 saveSettingsDebounced();
1577 await loadRegexScripts();1840 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);
1578 });1871 });
15791872
1580 $('#bulk_delete_regex').on('click', async function () {1873 $('#bulk_delete_regex').on('click', async function () {
@@ -1583,16 +1876,16 @@ jQuery(async () => {
1583 toastr.warning(t`No regex scripts selected for deletion.`);1876 toastr.warning(t`No regex scripts selected for deletion.`);
1584 return;1877 return;
1585 }1878 }
1586 const confirm = await callGenericPopup('Are you sure you want to delete the selected regex scripts?', POPUP_TYPE.CONFIRM);1879 const confirm = await callGenericPopup(t`Are you sure you want to delete the selected regex scripts?`, POPUP_TYPE.CONFIRM);
1587 if (!confirm) {1880 if (!confirm) {
1588 return;1881 return;
1589 }1882 }
1590 for (const script of scripts) {1883 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 });
1593 }1885 }
1594 await reloadCurrentChat();
1595 saveSettingsDebounced();1886 saveSettingsDebounced();
1887 await loadRegexScripts();
1888 await reloadCurrentChat();
1596 });1889 });
15971890
1598 $('#bulk_export_regex').on('click', async function () {1891 $('#bulk_export_regex').on('click', async function () {
@@ -1610,13 +1903,18 @@ jQuery(async () => {
1610 let sortableDatas = [1903 let sortableDatas = [
1611 {1904 {
1612 selector: '#saved_regex_scripts',1905 selector: '#saved_regex_scripts',
1613 setter: x => extension_settings.regex = x,1906 setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.GLOBAL),
1614 getter: () => extension_settings.regex ?? [],1907 getter: () => getScriptsByType(SCRIPT_TYPES.GLOBAL),
1615 },1908 },
1616 {1909 {
1617 selector: '#saved_scoped_scripts',1910 selector: '#saved_scoped_scripts',
1618 setter: x => writeExtensionField(this_chid, 'regex_scripts', x),1911 setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.SCOPED),
1619 getter: () => characters[this_chid]?.data?.extensions?.regex_scripts ?? [],1912 getter: () => getScriptsByType(SCRIPT_TYPES.SCOPED),
1913 },
1914 {
1915 selector: '#saved_preset_scripts',
1916 setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.PRESET),
1917 getter: () => getScriptsByType(SCRIPT_TYPES.PRESET),
1620 },1918 },
1621 ];1919 ];
1622 for (const { selector, setter, getter } of sortableDatas) {1920 for (const { selector, setter, getter } of sortableDatas) {
@@ -1638,6 +1936,7 @@ jQuery(async () => {
1638 saveSettingsDebounced();1936 saveSettingsDebounced();
16391937
1640 console.debug(`Regex scripts in ${selector} reordered`);1938 console.debug(`Regex scripts in ${selector} reordered`);
1939 await reloadCurrentChat();
1641 await loadRegexScripts();1940 await loadRegexScripts();
1642 },1941 },
1643 });1942 });
@@ -1655,17 +1954,26 @@ jQuery(async () => {
1655 }1954 }
16561955
1657 const isEnable = !!$(this).prop('checked');1956 const isEnable = !!$(this).prop('checked');
1658 const avatar = characters[this_chid].avatar;1957 const character = characters[this_chid];
16591958
1660 if (isEnable) {1959 if (isEnable) {
1661 if (!extension_settings.character_allowed_regex.includes(avatar)) {1960 allowScopedScripts(character);
1662 extension_settings.character_allowed_regex.push(avatar);
1663 }
1664 } else {1961 } else {
1665 const index = extension_settings.character_allowed_regex.indexOf(avatar);1962 disallowScopedScripts(character);
1666 if (index !== -1) {
1667 extension_settings.character_allowed_regex.splice(index, 1);
1668 }1963 }
1964
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);
1669 }1977 }
16701978
1671 saveSettingsDebounced();1979 saveSettingsDebounced();
@@ -1676,11 +1984,57 @@ jQuery(async () => {
1676 // @ts-ignore1984 // @ts-ignore
1677 $('#saved_regex_scripts').sortable('enable');1985 $('#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
1679 const localEnumProviders = {2027 const localEnumProviders = {
1680 regexScripts: () => getRegexScripts().map(script => {2028 regexScripts: () =>
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);
2032 return new SlashCommandEnumValue(
2033 script.scriptName,
2034 `${enumIcons.getStateIcon(!script.disabled)} [${typename}] ${script.findRegex}`,
2035 color,
2036 icon,
2037 );
1684 }),2038 }),
1685 };2039 };
16862040
@@ -1750,8 +2104,12 @@ jQuery(async () => {
1750 `,2104 `,
1751 }));2105 }));
17522106
1753 eventSource.on(event_types.CHAT_CHANGED, checkEmbeddedRegexScripts);2107 eventSource.on(event_types.MAIN_API_CHANGED, onMainApiChanged);
2108 eventSource.on(event_types.CHAT_CHANGED, checkCharEmbeddedRegexScripts);
1754 eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts);2109 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
1756 presetManager.setupEventListeners();2114 presetManager.setupEventListeners();
1757 presetManager.registerSlashCommands();2115 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+16 -7
@@ -1,25 +1,34 @@
1<div class="regex-script-label flex-container flexnowrap">1<div class="regex-script-label flex-container flexnowrap">
2 <input type="checkbox" class="regex_bulk_checkbox" />2 <input type="checkbox" class="regex_bulk_checkbox" />
3 <span class="drag-handle menu-handle">&#9776;</span>3 <span class="drag-handle menu-handle">&#9776;</span>
4 <div class="regex_script_name flexGrow overflow-hidden"></div>4 <div class="regex_script_name flex1 overflow-hidden"></div>
5 <div class="flex-container flexnowrap">5 <div class="flex-container flexnowrap">
6 <label class="checkbox flex-container" for="regex_disable">6 <label class="checkbox flex-container margin-r5" for="regex_disable">
7 <input type="checkbox" name="regex_disable" class="disable_regex" />7 <input type="checkbox" name="regex_disable" class="disable_regex" />
8 <span class="regex-toggle-on fa-solid fa-toggle-on" data-i18n="[title]ext_regex_disable_script" title="Disable script"></span>8 <span class="regex-toggle-on fa-solid fa-toggle-on" data-i18n="[title]ext_regex_disable_script" title="Disable script"></span>
9 <span class="regex-toggle-off fa-solid fa-toggle-off" data-i18n="[title]ext_regex_enable_script" title="Enable script"></span>9 <span class="regex-toggle-off fa-solid fa-toggle-off" data-i18n="[title]ext_regex_enable_script" title="Enable script"></span>
10 </label>10 </label>
11 <div class="edit_existing_regex menu_button" data-i18n="[title]ext_regex_edit_script" title="Edit script">11 <label class="menu_button regex_script_expand" title="Show more options" data-i18n="[title]Show more options">
12 <i class="fa-solid fa-pencil"></i>12 <input type="checkbox" name="regex_expand" />
13 </div>13 <span class="fa-solid fa-ellipsis"></span>
14 </label>
15 <div class="flex-container regex_script_buttons">
14 <div class="move_to_global menu_button" data-i18n="[title]ext_regex_move_to_global" title="Move to global scripts">16 <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>17 <i class="fa-solid fa-globe"></i>
16 </div>18 </div>
17 <div class="move_to_scoped menu_button" data-i18n="[title]ext_regex_move_to_scoped" title="Move to scoped scripts">19 <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>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>
19 </div>24 </div>
20 <div class="export_regex menu_button" data-i18n="[title]ext_regex_export_script" title="Export script">25 <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>26 <i class="fa-solid fa-file-export"></i>
22 </div>27 </div>
28 </div>
29 <div class="edit_existing_regex menu_button" data-i18n="[title]ext_regex_edit_script" title="Edit script">
30 <i class="fa-solid fa-pencil"></i>
31 </div>
23 <div class="delete_regex menu_button" data-i18n="[title]ext_regex_delete_script" title="Delete script">32 <div class="delete_regex menu_button" data-i18n="[title]ext_regex_delete_script" title="Delete script">
24 <i class="fa-solid fa-trash"></i>33 <i class="fa-solid fa-trash"></i>
25 </div>34 </div>
public/scripts/extensions/regex/style.css+35 -26
@@ -24,39 +24,33 @@
24 text-align: center;24 text-align: center;
25}25}
2626
27#scoped_scripts_block {27#scoped_scripts_block,
28#preset_scripts_block {
28 opacity: 1;29 opacity: 1;
29 transition: opacity var(--animation-duration-2x) ease-in-out;30 transition: opacity var(--animation-duration-2x) ease-in-out;
30}31}
3132
32#scoped_scripts_block .move_to_scoped {33#scoped_scripts_block .move_to_scoped,
34#global_scripts_block .move_to_global,
35#preset_scripts_block .move_to_preset {
33 display: none;36 display: none;
34}37}
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)) {
41 opacity: 0.5;41 opacity: 0.5;
42}42}
4343
44.enable_scoped:checked~.regex-toggle-on {44.enable_scoped:checked~.regex-toggle-on,
45.enable_scoped:not(:checked)~.regex-toggle-off {
45 display: block;46 display: block;
46}47}
4748
48.enable_scoped:checked~.regex-toggle-off {49.enable_scoped:checked~.regex-toggle-off,
49 display: none;
50}
51
52.enable_scoped:not(:checked)~.regex-toggle-on {50.enable_scoped:not(:checked)~.regex-toggle-on {
53 display: none;51 display: none;
54}52}
5553
56.enable_scoped:not(:checked)~.regex-toggle-off {
57 display: block;
58}
59
60.regex-script-label {54.regex-script-label {
61 align-items: baseline;55 align-items: baseline;
62 border: 1px solid var(--SmartThemeBorderColor);56 border: 1px solid var(--SmartThemeBorderColor);
@@ -92,19 +86,13 @@ input.enable_scoped {
92 cursor: pointer;86 cursor: pointer;
93}87}
9488
95.disable_regex:checked~.regex-toggle-off {89.disable_regex:checked~.regex-toggle-on,
96 display: block;
97}
98
99.disable_regex:checked~.regex-toggle-on {
100 display: none;
101}
102
103.disable_regex:not(:checked)~.regex-toggle-off {90.disable_regex:not(:checked)~.regex-toggle-off {
104 display: none;91 display: none;
105}92}
10693
107.disable_regex:not(:checked)~.regex-toggle-on {94.disable_regex:not(:checked)~.regex-toggle-on,
95.disable_regex:checked~.regex-toggle-off {
108 display: block;96 display: block;
109}97}
11098
@@ -139,7 +127,8 @@ input.enable_scoped {
139}127}
140128
141.regex_settings .regex_bulk_operations,129.regex_settings .regex_bulk_operations,
142.regex_settings .regex_bulk_checkbox {130.regex_settings .regex_bulk_checkbox,
131.regex_settings .regex_bulk_operations_hr {
143 display: none;132 display: none;
144}133}
145134
@@ -147,6 +136,26 @@ input.enable_scoped {
147 display: flex;136 display: flex;
148}137}
149138
139.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_operations_hr {
140 display: block;
141}
142
150.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_checkbox {143.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_checkbox {
151 display: inline-grid;144 display: inline-grid;
152}145}
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 {
410 custom_url: profile['api-url'],410 custom_url: profile['api-url'],
411 reverse_proxy: proxyPreset?.url,411 reverse_proxy: proxyPreset?.url,
412 proxy_password: proxyPreset?.password,412 proxy_password: proxyPreset?.password,
413 custom_prompt_post_processing: profile['prompt-post-processing'],
413 ...overridePayload,414 ...overridePayload,
414 }, {415 }, {
415 presetName: includePreset ? profile.preset : undefined,416 presetName: includePreset ? profile.preset : undefined,
public/scripts/extensions/stable-diffusion/dropdown.html+1 -1
@@ -1,6 +1,6 @@
1<div id="sd_dropdown">1<div id="sd_dropdown">
2 <ul class="list-group">2 <ul class="list-group">
3 <span>Send me a picture of:</span>3 <span data-i18n="Send me a picture of:">Send me a picture of:</span>
4 <li class="list-group-item" id="sd_you" data-value="you" data-i18n="sd_Yourself">Yourself</li>4 <li class="list-group-item" id="sd_you" data-value="you" data-i18n="sd_Yourself">Yourself</li>
5 <li class="list-group-item" id="sd_face" data-value="face" data-i18n="sd_Your_Face">Your Face</li>5 <li class="list-group-item" id="sd_face" data-value="face" data-i18n="sd_Your_Face">Your Face</li>
6 <li class="list-group-item" id="sd_me" data-value="me" data-i18n="sd_Me">Me</li>6 <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() {
1705 models = await loadStabilityModels();1705 models = await loadStabilityModels();
1706 break;1706 break;
1707 case sources.huggingface:1707 case sources.huggingface:
1708 models = [{ value: '', text: '<Enter Model ID above>' }];1708 models = [{ value: '', text: t`<Enter Model ID above>` }];
1709 break;1709 break;
1710 case sources.electronhub:1710 case sources.electronhub:
1711 models = await loadElectronHubModels();1711 models = await loadElectronHubModels();
public/scripts/extensions/stable-diffusion/settings.html+6 -6
@@ -90,15 +90,15 @@
90 <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>90 <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>
91 </div>91 </div>
92 <div data-sd-source="huggingface">92 <div data-sd-source="huggingface">
93 <i>Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.</i>93 <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>
94 <label for="sd_huggingface_model_id" data-i18n="Model ID">Model ID</label>94 <label for="sd_huggingface_model_id" data-i18n="Model ID">Model ID</label>
95 <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="" />95 <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="" />
96 </div>96 </div>
97 <div data-sd-source="electronhub">97 <div data-sd-source="electronhub">
98 <i>Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.</i>98 <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>
99 </div>99 </div>
100 <div data-sd-source="nanogpt">100 <div data-sd-source="nanogpt">
101 <i>Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.</i>101 <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>
102 </div>102 </div>
103 <div data-sd-source="vlad">103 <div data-sd-source="vlad">
104 <label for="sd_vlad_url">SD.Next API URL</label>104 <label for="sd_vlad_url">SD.Next API URL</label>
@@ -143,7 +143,7 @@
143 View my Anlas143 View my Anlas
144 </div>144 </div>
145 </div>145 </div>
146 <i>Hint: Save an API key in the NovelAI API settings to use it here.</i>146 <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>
147 </div>147 </div>
148 <div data-sd-source="aimlapi">148 <div data-sd-source="aimlapi">
149 <div class="flex-container flexnowrap alignItemsBaseline marginBot5">149 <div class="flex-container flexnowrap alignItemsBaseline marginBot5">
@@ -204,7 +204,7 @@
204 <a href="https://pollinations.ai">Pollinations.ai</a>204 <a href="https://pollinations.ai">Pollinations.ai</a>
205 </p>205 </p>
206 <div class="flex-container">206 <div class="flex-container">
207 <label class="flex1 checkbox_label" for="sd_pollinations_enhance" title="Enables prompt enhancing (passes prompts through an LLM to add detail).">207 <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).">
208 <input id="sd_pollinations_enhance" type="checkbox" />208 <input id="sd_pollinations_enhance" type="checkbox" />
209 <span data-i18n="Enhance">209 <span data-i18n="Enhance">
210 Enhance210 Enhance
@@ -297,7 +297,7 @@
297 </div>297 </div>
298 </div>298 </div>
299 <div class="flex-container">299 <div class="flex-container">
300 <label class="flex1 checkbox_label" for="sd_google_enhance" title="Enables prompt enhancing (passes prompts through an LLM to add detail).">300 <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).">
301 <input id="sd_google_enhance" type="checkbox" />301 <input id="sd_google_enhance" type="checkbox" />
302 <span data-i18n="Enhance">302 <span data-i18n="Enhance">
303 Enhance303 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
1.minimax-custom-item {6.minimax-custom-item {
2 display: flex;7 display: flex;
3 justify-content: space-between;8 justify-content: space-between;
public/scripts/extensions/tts/electronhub.js+455 -0
@@ -0,0 +1,455 @@
1import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
2import { SECRET_KEYS, secret_state } from '../../secrets.js';
3import { getPreviewString, saveTtsProviderSettings, initVoiceMap } from './index.js';
4
5export { ElectronHubTtsProvider };
6
7class 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 {
92 }92 }
9393
94 // Set initial values from the settings94 // Set initial values from the settings
95 $('#tts_endpoint').val(this.settings.provider_endpoint);95 $('#tts_endpoint').val(this.settings.provider_endpoint).on('change', this.onSettingsChange.bind(this));
96 $('#text_lang').val(this.settings.text_lang);96 $('#text_lang').val(this.settings.text_lang).on('change', this.onSettingsChange.bind(this));
97 $('#prompt_lang').val(this.settings.prompt_lang);97 $('#prompt_lang').val(this.settings.prompt_lang).on('change', this.onSettingsChange.bind(this));
98
9998
100 await this.checkReady();99 await this.checkReady();
101100
@@ -108,7 +107,7 @@ class GptSovitsV2Provider {
108 }107 }
109108
110 async onRefreshClick() {109 async onRefreshClick() {
111 return;110 return await this.checkReady();
112 }111 }
113112
114 //#################//113 //#################//
public/scripts/extensions/tts/index.js+2 -0
@@ -33,6 +33,7 @@ import { KokoroTtsProvider } from './kokoro.js';
33import { TtsWebuiProvider } from './tts-webui.js';33import { TtsWebuiProvider } from './tts-webui.js';
34import { PollinationsTtsProvider } from './pollinations.js';34import { PollinationsTtsProvider } from './pollinations.js';
35import { MiniMaxTtsProvider } from './minimax.js';35import { MiniMaxTtsProvider } from './minimax.js';
36import { ElectronHubTtsProvider } from './electronhub.js';
3637
37const UPDATE_INTERVAL = 1000;38const UPDATE_INTERVAL = 1000;
38const wrapper = new ModuleWorkerWrapper(moduleWorker);39const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -123,6 +124,7 @@ const ttsProviders = {
123 'CosyVoice (Unofficial)': CosyVoiceProvider,124 'CosyVoice (Unofficial)': CosyVoiceProvider,
124 Edge: EdgeTtsProvider,125 Edge: EdgeTtsProvider,
125 ElevenLabs: ElevenLabsTtsProvider,126 ElevenLabs: ElevenLabsTtsProvider,
127 'Electron Hub': ElectronHubTtsProvider,
126 'Google Translate': GoogleTranslateTtsProvider,128 'Google Translate': GoogleTranslateTtsProvider,
127 'Google Gemini TTS': GoogleNativeTtsProvider,129 'Google Gemini TTS': GoogleNativeTtsProvider,
128 GSVI: GSVITtsProvider,130 GSVI: GSVITtsProvider,
public/scripts/extensions/tts/minimax.js+44 -16
@@ -19,9 +19,9 @@ class MiniMaxTtsProvider {
19 apiHost: 'https://api.minimax.io',19 apiHost: 'https://api.minimax.io',
20 model: 'speech-02-hd',20 model: 'speech-02-hd',
21 voiceMap: {},21 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 },
25 audioSampleRate: 32000,25 audioSampleRate: 32000,
26 bitrate: 128000,26 bitrate: 128000,
27 format: 'mp3',27 format: 'mp3',
@@ -84,15 +84,15 @@ class MiniMaxTtsProvider {
8484
85 <div class="tts_block">85 <div class="tts_block">
86 <label for="minimax_tts_speed">Speed: <span id="minimax_tts_speed_output"></span></label>86 <label for="minimax_tts_speed">Speed: <span id="minimax_tts_speed_output"></span></label>
87 <input id="minimax_tts_speed" type="range" value="${this.defaultSettings.speed}" min="0.5" max="2.0" step="0.1" />87 <input id="minimax_tts_speed" type="range" value="${this.defaultSettings.speed.default}" min="${this.defaultSettings.speed.min}" max="${this.defaultSettings.speed.max}" step="${this.defaultSettings.speed.step}" />
88 </div>88 </div>
89 <div class="tts_block">89 <div class="tts_block">
90 <label for="minimax_tts_volume">Volume: <span id="minimax_tts_volume_output"></span></label>90 <label for="minimax_tts_volume">Volume: <span id="minimax_tts_volume_output"></span></label>
91 <input id="minimax_tts_volume" type="range" value="${this.defaultSettings.volume}" min="0.1" max="2.0" step="0.1" />91 <input id="minimax_tts_volume" type="range" value="${this.defaultSettings.volume.default}" min="${this.defaultSettings.volume.min}" max="${this.defaultSettings.volume.max}" step="${this.defaultSettings.volume.step}" />
92 </div>92 </div>
93 <div class="tts_block">93 <div class="tts_block">
94 <label for="minimax_tts_pitch">Pitch: <span id="minimax_tts_pitch_output"></span></label>94 <label for="minimax_tts_pitch">Pitch: <span id="minimax_tts_pitch_output"></span></label>
95 <input id="minimax_tts_pitch" type="range" value="${this.defaultSettings.pitch}" min="0.5" max="2.0" step="0.1" />95 <input id="minimax_tts_pitch" type="range" value="${this.defaultSettings.pitch.default}" min="${this.defaultSettings.pitch.min}" max="${this.defaultSettings.pitch.max}" step="${this.defaultSettings.pitch.step}" />
96 </div>96 </div>
97 <div class="tts_block">97 <div class="tts_block">
98 <label for="minimax_tts_format">Audio Format</label>98 <label for="minimax_tts_format">Audio Format</label>
@@ -190,14 +190,14 @@ class MiniMaxTtsProvider {
190 this.settings.apiHost = $('#minimax_tts_api_host').val();190 this.settings.apiHost = $('#minimax_tts_api_host').val();
191 this.settings.speed = parseFloat($('#minimax_tts_speed').val().toString());191 this.settings.speed = parseFloat($('#minimax_tts_speed').val().toString());
192 this.settings.volume = parseFloat($('#minimax_tts_volume').val().toString());192 this.settings.volume = parseFloat($('#minimax_tts_volume').val().toString());
193 this.settings.pitch = parseFloat($('#minimax_tts_pitch').val().toString());193 this.settings.pitch = parseInt($('#minimax_tts_pitch').val().toString());
194 this.settings.model = $('#minimax_tts_model').find(':selected').val();194 this.settings.model = $('#minimax_tts_model').find(':selected').val();
195 this.settings.format = $('#minimax_tts_format').find(':selected').val();195 this.settings.format = $('#minimax_tts_format').find(':selected').val();
196 this.settings.customVoiceId = $('#minimax_tts_custom_voice_id').val();196 this.settings.customVoiceId = $('#minimax_tts_custom_voice_id').val();
197197
198 $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));198 $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));
199 $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));199 $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));
200 $('#minimax_tts_pitch_output').text(this.settings.pitch.toFixed(1));200 $('#minimax_tts_pitch_output').text(this.settings.pitch);
201201
202 saveTtsProviderSettings();202 saveTtsProviderSettings();
203 }203 }
@@ -458,6 +458,16 @@ class MiniMaxTtsProvider {
458 // Only accept keys defined in defaultSettings458 // Only accept keys defined in defaultSettings
459 this.settings = { ...this.defaultSettings };459 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
461 for (const key in settings) {471 for (const key in settings) {
462 if (key in this.settings) {472 if (key in this.settings) {
463 this.settings[key] = settings[key];473 this.settings[key] = settings[key];
@@ -470,6 +480,21 @@ class MiniMaxTtsProvider {
470 if (!this.settings.customModels) this.settings.customModels = [];480 if (!this.settings.customModels) this.settings.customModels = [];
471 if (!this.settings.customVoices) this.settings.customVoices = [];481 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
473 $('#minimax_tts_api_host').val(this.settings.apiHost || 'https://api.minimax.io');498 $('#minimax_tts_api_host').val(this.settings.apiHost || 'https://api.minimax.io');
474 $('#minimax_tts_model').val(this.settings.model);499 $('#minimax_tts_model').val(this.settings.model);
475 $('#minimax_tts_speed').val(this.settings.speed);500 $('#minimax_tts_speed').val(this.settings.speed);
@@ -546,7 +571,7 @@ class MiniMaxTtsProvider {
546571
547 $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));572 $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));
548 $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));573 $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));
549 $('#minimax_tts_pitch_output').text(this.settings.pitch.toFixed(1));574 $('#minimax_tts_pitch_output').text(this.settings.pitch);
550575
551 // Initialize custom configuration display576 // Initialize custom configuration display
552 this.updateCustomModelsDisplay();577 this.updateCustomModelsDisplay();
@@ -756,17 +781,20 @@ class MiniMaxTtsProvider {
756 throw error;781 throw error;
757 }782 }
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
759 const requestBody = {787 const requestBody = {
760 text: inputText,788 text: inputText,
761 voiceId: voiceId,789 voiceId: voiceId,
762 apiHost: this.settings.apiHost,790 apiHost: this.settings.apiHost,
763 model: this.settings.model || 'speech-02-hd',791 model: this.settings.model || this.defaultSettings.model,
764 speed: Number(this.settings.speed) || 1.0,792 speed: clamp(Number(this.settings.speed) || this.defaultSettings.speed.default, this.defaultSettings.speed.min, this.defaultSettings.speed.max),
765 volume: Number(this.settings.volume) || 1.0,793 volume: clamp(Number(this.settings.volume) || this.defaultSettings.volume.default, this.defaultSettings.volume.min, this.defaultSettings.volume.max),
766 pitch: Number(this.settings.pitch) || 1.0,794 pitch: clamp(Math.round(Number(this.settings.pitch)) || this.defaultSettings.pitch.default, this.defaultSettings.pitch.min, this.defaultSettings.pitch.max),
767 audioSampleRate: Number(this.settings.audioSampleRate) || 32000,795 audioSampleRate: Number(this.settings.audioSampleRate) || this.defaultSettings.audioSampleRate,
768 bitrate: Number(this.settings.bitrate) || 128000,796 bitrate: Number(this.settings.bitrate) || this.defaultSettings.bitrate,
769 format: this.settings.format || 'mp3',797 format: this.settings.format || this.defaultSettings.format,
770 language: language,798 language: language,
771 };799 };
772800
public/scripts/extensions/tts/settings.html+2 -2
@@ -12,7 +12,7 @@
12 <div class="tts_block">12 <div class="tts_block">
13 <select id="tts_provider" class="flex1">13 <select id="tts_provider" class="flex1">
14 </select>14 </select>
15 <input id="tts_refresh" class="menu_button" type="submit" value="Reload" />15 <input id="tts_refresh" data-i18n="[value]tts_refresh" class="menu_button" type="submit" value="Reload" />
16 </div>16 </div>
17 <div>17 <div>
18 <label class="checkbox_label" for="tts_enabled">18 <label class="checkbox_label" for="tts_enabled">
@@ -88,7 +88,7 @@
88 <form id="tts_provider_settings">88 <form id="tts_provider_settings">
89 </form>89 </form>
90 <div class="tts_buttons">90 <div class="tts_buttons">
91 <input id="tts_voices" class="menu_button" type="submit" value="Available voices" />91 <input id="tts_voices" class="menu_button" data-i18n="[value]Available voices" type="submit" value="Available voices" />
92 </div>92 </div>
93 </div>93 </div>
94 </div>94 </div>
public/scripts/extensions/tts/system.js+5 -4
@@ -2,6 +2,7 @@ import { isMobile } from '../../RossAscends-mods.js';
2import { getPreviewString } from './index.js';2import { getPreviewString } from './index.js';
3import { saveTtsProviderSettings } from './index.js';3import { saveTtsProviderSettings } from './index.js';
4export { SystemTtsProvider };4export { SystemTtsProvider };
5import { t } from '../../i18n.js';
56
6/**7/**
7 * Chunkify8 * Chunkify
@@ -96,13 +97,13 @@ class SystemTtsProvider {
9697
97 get settingsHtml() {98 get settingsHtml() {
98 if (!('speechSynthesis' in window)) {99 if (!('speechSynthesis' in window)) {
99 return 'Your browser or operating system doesn\'t support speech synthesis';100 return t`Your browser or operating system doesn't support speech synthesis`;
100 }101 }
101102
102 return `<p>Uses the voices provided by your operating system</p>103 return '<p>' + t`Uses the voices provided by your operating system` + `</p>
103 <label for="system_tts_rate">Rate: <span id="system_tts_rate_output"></span></label>104 <label for="system_tts_rate">` + t`Rate:` + ` <span id="system_tts_rate_output"></span></label>
104 <input id="system_tts_rate" type="range" value="${this.defaultSettings.rate}" min="0.1" max="2" step="0.01" />105 <input id="system_tts_rate" type="range" value="${this.defaultSettings.rate}" min="0.1" max="2" step="0.01" />
105 <label for="system_tts_pitch">Pitch: <span id="system_tts_pitch_output"></span></label>106 <label for="system_tts_pitch">` + t`Pitch:` + ` <span id="system_tts_pitch_output"></span></label>
106 <input id="system_tts_pitch" type="range" value="${this.defaultSettings.pitch}" min="0" max="2" step="0.01" />`;107 <input id="system_tts_pitch" type="range" value="${this.defaultSettings.pitch}" min="0" max="2" step="0.01" />`;
107 }108 }
108109
public/scripts/extensions/vectors/index.js+1 -1
@@ -66,7 +66,7 @@ const settings = {
66 ollama_keep: false,66 ollama_keep: false,
67 vllm_model: '',67 vllm_model: '',
68 webllm_model: '',68 webllm_model: '',
69 google_model: 'text-embedding-004',69 google_model: 'text-embedding-005',
70 summarize: false,70 summarize: false,
71 summarize_sent: false,71 summarize_sent: false,
72 summary_source: 'main',72 summary_source: 'main',
public/scripts/extensions/vectors/settings.html+1 -0
@@ -140,6 +140,7 @@
140 <option value="gemini-embedding-001">gemini-embedding-001</option>140 <option value="gemini-embedding-001">gemini-embedding-001</option>
141 <option value="gemini-embedding-exp-03-07">gemini-embedding-exp-03-07</option>141 <option value="gemini-embedding-exp-03-07">gemini-embedding-exp-03-07</option>
142 <option value="text-embedding-004">text-embedding-004</option>142 <option value="text-embedding-004">text-embedding-004</option>
143 <option value="text-embedding-005">text-embedding-005</option>
143 <option value="embedding-001">embedding-001</option>144 <option value="embedding-001">embedding-001</option>
144 </select>145 </select>
145 </div>146 </div>
public/scripts/group-chats.js+33 -16
@@ -76,6 +76,7 @@ import {
76 depth_prompt_role_default,76 depth_prompt_role_default,
77 shouldAutoContinue,77 shouldAutoContinue,
78 unshallowCharacter,78 unshallowCharacter,
79 chatElement,
79} from '../script.js';80} from '../script.js';
80import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';81import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
81import { FILTER_TYPES, FilterHelper } from './filters.js';82import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -236,17 +237,13 @@ export async function getGroupChat(groupId, reload = false) {
236 const chat_id = group.chat_id;237 const chat_id = group.chat_id;
237 const data = await loadGroupChat(chat_id);238 const data = await loadGroupChat(chat_id);
238 const metadata = group.chat_metadata ?? {};239 const metadata = group.chat_metadata ?? {};
239 let freshChat = false;240 const freshChat = !metadata.tainted;
240241
241 await loadItemizedPrompts(getCurrentChatId());242 await loadItemizedPrompts(getCurrentChatId());
242243
243 if (Array.isArray(data) && data.length) {
244 data[0].is_group = true;
245 chat.splice(0, chat.length, ...data);
246 await printMessages();
247 } else {
248 freshChat = !metadata.tainted;
249 if (group && Array.isArray(group.members) && freshChat) {244 if (group && Array.isArray(group.members) && freshChat) {
245 chat.splice(0, chat.length);
246 chatElement.find('.mes').remove();
250 for (let member of group.members) {247 for (let member of group.members) {
251 const character = characters.find(x => x.avatar === member || x.name === member);248 const character = characters.find(x => x.avatar === member || x.name === member);
252 if (!character) {249 if (!character) {
@@ -266,7 +263,11 @@ export async function getGroupChat(groupId, reload = false) {
266 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, (chat.length - 1), 'first_message');263 await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, (chat.length - 1), 'first_message');
267 }264 }
268 await saveGroupChat(groupId, false);265 await saveGroupChat(groupId, false);
269 }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();
270 }271 }
271272
272 updateChatMetadata(metadata, true);273 updateChatMetadata(metadata, true);
@@ -2018,7 +2019,14 @@ export async function deleteGroupChatByName(groupId, chatName) {
2018 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatName);2019 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatName);
2019}2020}
20202021
2021export 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 */
2029export async function deleteGroupChat(groupId, chatId, { jumpToNewChat = true } = {}) {
2022 const group = groups.find(x => x.id === groupId);2030 const group = groups.find(x => x.id === groupId);
20232031
2024 if (!group || !group.chats.includes(chatId)) {2032 if (!group || !group.chats.includes(chatId)) {
@@ -2026,10 +2034,13 @@ export async function deleteGroupChat(groupId, chatId) {
2026 }2034 }
20272035
2028 group.chats.splice(group.chats.indexOf(chatId), 1);2036 group.chats.splice(group.chats.indexOf(chatId), 1);
2029 group.chat_metadata = {};
2030 group.chat_id = '';
2031 delete group.past_metadata[chatId];2037 delete group.past_metadata[chatId];
2038
2039 if (group.chat_id === chatId) {
2040 group.chat_id = '';
2041 group.chat_metadata = {};
2032 updateChatMetadata(group.chat_metadata, true);2042 updateChatMetadata(group.chat_metadata, true);
2043 }
20332044
2034 const response = await fetch('/api/chats/group/delete', {2045 const response = await fetch('/api/chats/group/delete', {
2035 method: 'POST',2046 method: 'POST',
@@ -2038,11 +2049,13 @@ export async function deleteGroupChat(groupId, chatId) {
2038 });2049 });
20392050
2040 if (response.ok) {2051 if (response.ok) {
2052 if (jumpToNewChat) {
2041 if (group.chats.length) {2053 if (group.chats.length) {
2042 await openGroupChat(groupId, group.chats[group.chats.length - 1]);2054 await openGroupChat(groupId, group.chats[group.chats.length - 1]);
2043 } else {2055 } else {
2044 await createNewGroupChat(groupId);2056 await createNewGroupChat(groupId);
2045 }2057 }
2058 }
20462059
2047 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatId);2060 await eventSource.emit(event_types.GROUP_CHAT_DELETED, chatId);
2048 }2061 }
@@ -2051,9 +2064,11 @@ export async function deleteGroupChat(groupId, chatId) {
2051/**2064/**
2052 * Imports a group chat from a file and adds it to the group.2065 * Imports a group chat from a file and adds it to the group.
2053 * @param {FormData} formData Form data to send to the server2066 * @param {FormData} formData Form data to send to the server
2054 * @param {EventTarget} eventTarget Element that triggered the import2067 * @param {object} [options={}] Options for 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
2055 */2070 */
2056export async function importGroupChat(formData, eventTarget) {2071export async function importGroupChat(formData, { refresh = true } = {}) {
2057 const fetchResult = await fetch('/api/chats/group/import', {2072 const fetchResult = await fetch('/api/chats/group/import', {
2058 method: 'POST',2073 method: 'POST',
2059 headers: getRequestHeaders({ omitContentType: true }),2074 headers: getRequestHeaders({ omitContentType: true }),
@@ -2070,14 +2085,16 @@ export async function importGroupChat(formData, eventTarget) {
2070 if (group) {2085 if (group) {
2071 group.chats.push(chatId);2086 group.chats.push(chatId);
2072 await editGroup(selected_group, true, true);2087 await editGroup(selected_group, true, true);
2088 if (refresh) {
2073 await displayPastChats();2089 await displayPastChats();
2074 }2090 }
2075 }2091 }
2076 }2092 }
20772093
2078 if (eventTarget instanceof HTMLInputElement) {2094 return data?.fileNames || [];
2079 eventTarget.value = '';
2080 }2095 }
2096
2097 return [];
2081}2098}
20822099
2083export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {2100export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
@@ -2147,7 +2164,7 @@ function doCurMemberListPopout() {
2147 // Remove pagination from popout2164 // Remove pagination from popout
2148 newElement.find('.group_pagination').empty();2165 newElement.find('.group_pagination').empty();
21492166
2150 $('body').append(newElement);2167 $('#movingDivs').append(newElement);
2151 loadMovingUIState();2168 loadMovingUIState();
2152 $('#groupMemberListPopout').fadeIn(animation_duration);2169 $('#groupMemberListPopout').fadeIn(animation_duration);
2153 dragElement(newElement);2170 dragElement(newElement);
public/scripts/macros.js+31 -1
@@ -1,10 +1,11 @@
1import { Handlebars, moment, seedrandom, droll } from '../lib.js';1import { Handlebars, moment, seedrandom, droll } from '../lib.js';
2import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams, eventSource, event_types } from '../script.js';2import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams, eventSource, event_types, extension_prompts } from '../script.js';
3import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';3import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';
4import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';4import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';
5import { getInstructMacros } from './instruct-mode.js';5import { getInstructMacros } from './instruct-mode.js';
6import { getVariableMacros } from './variables.js';6import { getVariableMacros } from './variables.js';
7import { isMobile } from './RossAscends-mods.js';7import { isMobile } from './RossAscends-mods.js';
8import { inject_ids } from './constants.js';
89
9/**10/**
10 * @typedef Macro11 * @typedef Macro
@@ -56,6 +57,24 @@ export class MacrosParser {
56 };57 };
5758
58 /**59 /**
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 /**
59 * Registers a global macro that can be used anywhere where substitution is allowed.78 * Registers a global macro that can be used anywhere where substitution is allowed.
60 * @param {string} key Macro name (key)79 * @param {string} key Macro name (key)
61 * @param {string|MacroFunction} value A string or a function that returns a string80 * @param {string|MacroFunction} value A string or a function that returns a string
@@ -459,6 +478,16 @@ function getTimeDiffMacro() {
459}478}
460479
461/**480/**
481 * Returns the outlet prompt for a given outlet key.
482 * @param {string} key - The outlet key
483 * @returns {string} The outlet prompt
484 */
485function getOutletPrompt(key) {
486 const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(key)]?.value;
487 return value || '';
488}
489
490/**
462 * Substitutes {{macro}} parameters in a string.491 * Substitutes {{macro}} parameters in a string.
463 * @param {string} content - The string to substitute parameters in.492 * @param {string} content - The string to substitute parameters in.
464 * @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param493 * @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) {
518 { regex: /{{datetimeformat +([^}]*)}}/gi, replace: (_, format) => moment().format(format) },547 { regex: /{{datetimeformat +([^}]*)}}/gi, replace: (_, format) => moment().format(format) },
519 { regex: /{{idle_duration}}/gi, replace: () => getTimeSinceLastMessage() },548 { regex: /{{idle_duration}}/gi, replace: () => getTimeSinceLastMessage() },
520 { regex: /{{time_UTC([-+]\d+)}}/gi, replace: (_, offset) => moment().utc().utcOffset(parseInt(offset, 10)).format('LT') },549 { regex: /{{time_UTC([-+]\d+)}}/gi, replace: (_, offset) => moment().utc().utcOffset(parseInt(offset, 10)).format('LT') },
550 { regex: /{{outlet::(.+?)}}/gi, replace: (_, key) => getOutletPrompt(key.trim()) || '' },
521 getTimeDiffMacro(),551 getTimeDiffMacro(),
522 getBannedWordsMacro(),552 getBannedWordsMacro(),
523 getRandomReplaceMacro(),553 getRandomReplaceMacro(),
public/scripts/openai.js+213 -116
@@ -276,6 +276,8 @@ export const settingsToUpdate = {
276 perplexity_model: ['#model_perplexity_select', 'perplexity_model', false, true],276 perplexity_model: ['#model_perplexity_select', 'perplexity_model', false, true],
277 groq_model: ['#model_groq_select', 'groq_model', false, true],277 groq_model: ['#model_groq_select', 'groq_model', false, true],
278 electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],278 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],
279 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],281 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],
280 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],282 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],
281 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],283 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],
@@ -370,15 +372,17 @@ const default_settings = {
370 scenario_format: default_scenario_format,372 scenario_format: default_scenario_format,
371 personality_format: default_personality_format,373 personality_format: default_personality_format,
372 openai_model: 'gpt-4-turbo',374 openai_model: 'gpt-4-turbo',
373 claude_model: 'claude-3-5-sonnet-20240620',375 claude_model: 'claude-sonnet-4-5',
374 google_model: 'gemini-1.5-pro',376 google_model: 'gemini-2.5-pro',
375 vertexai_model: 'gemini-2.0-flash-001',377 vertexai_model: 'gemini-2.5-pro',
376 ai21_model: 'jamba-large',378 ai21_model: 'jamba-large',
377 mistralai_model: 'mistral-large-latest',379 mistralai_model: 'mistral-large-latest',
378 cohere_model: 'command-r-plus',380 cohere_model: 'command-r-plus',
379 perplexity_model: 'sonar-pro',381 perplexity_model: 'sonar-pro',
380 groq_model: 'llama-3.3-70b-versatile',382 groq_model: 'llama-3.3-70b-versatile',
381 electronhub_model: 'gpt-4o-mini',383 electronhub_model: 'gpt-4o-mini',
384 electronhub_sort_models: 'alphabetically',
385 electronhub_group_models: false,
382 nanogpt_model: 'gpt-4o-mini',386 nanogpt_model: 'gpt-4o-mini',
383 deepseek_model: 'deepseek-chat',387 deepseek_model: 'deepseek-chat',
384 aimlapi_model: 'gpt-4o-mini-2024-07-18',388 aimlapi_model: 'gpt-4o-mini-2024-07-18',
@@ -464,15 +468,17 @@ const oai_settings = {
464 scenario_format: default_scenario_format,468 scenario_format: default_scenario_format,
465 personality_format: default_personality_format,469 personality_format: default_personality_format,
466 openai_model: 'gpt-4-turbo',470 openai_model: 'gpt-4-turbo',
467 claude_model: 'claude-3-5-sonnet-20240620',471 claude_model: 'claude-sonnet-4-5',
468 google_model: 'gemini-1.5-pro',472 google_model: 'gemini-2.5-pro',
469 vertexai_model: 'gemini-2.0-flash-001',473 vertexai_model: 'gemini-2.5-pro',
470 ai21_model: 'jamba-large',474 ai21_model: 'jamba-large',
471 mistralai_model: 'mistral-large-latest',475 mistralai_model: 'mistral-large-latest',
472 cohere_model: 'command-r-plus',476 cohere_model: 'command-r-plus',
473 perplexity_model: 'sonar-pro',477 perplexity_model: 'sonar-pro',
474 groq_model: 'llama-3.1-70b-versatile',478 groq_model: 'llama-3.1-70b-versatile',
475 electronhub_model: 'gpt-4o-mini',479 electronhub_model: 'gpt-4o-mini',
480 electronhub_sort_models: 'alphabetically',
481 electronhub_group_models: false,
476 nanogpt_model: 'gpt-4o-mini',482 nanogpt_model: 'gpt-4o-mini',
477 deepseek_model: 'deepseek-chat',483 deepseek_model: 'deepseek-chat',
478 aimlapi_model: 'gpt-4-turbo',484 aimlapi_model: 'gpt-4-turbo',
@@ -1710,6 +1716,59 @@ function calculateOpenRouterCost() {
1710 $('#openrouter_max_prompt_cost').text(cost);1716 $('#openrouter_max_prompt_cost').text(cost);
1711}1717}
17121718
1719function 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
1750function 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
1713function saveModelList(data) {1772function saveModelList(data) {
1714 model_list = data.map((model) => ({ ...model }));1773 model_list = data.map((model) => ({ ...model }));
1715 model_list.sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id));1774 model_list.sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id));
@@ -1775,22 +1834,10 @@ function saveModelList(data) {
1775 }1834 }
17761835
1777 if (oai_settings.chat_completion_source == chat_completion_sources.MISTRALAI) {1836 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 });
17881838
1789 const otherOptionsGroup = mistralModelSelect.querySelector('#mistralai_other_models');
1790 for (const model of model_list.filter(model => model?.capabilities?.completion_chat)) {1839 for (const model of model_list.filter(model => model?.capabilities?.completion_chat)) {
1791 if (!options.some(option => option.value === model.id) && otherOptionsGroup) {1840 $('#model_mistralai_select').append(new Option(model.id, model.id));
1792 otherOptionsGroup.append(new Option(model.id, model.id));
1793 }
1794 }1841 }
17951842
1796 const selectedModel = model_list.find(model => model.id === oai_settings.mistralai_model);1843 const selectedModel = model_list.find(model => model.id === oai_settings.mistralai_model);
@@ -1800,19 +1847,16 @@ function saveModelList(data) {
18001847
1801 $('#model_mistralai_select').val(oai_settings.mistralai_model).trigger('change');1848 $('#model_mistralai_select').val(oai_settings.mistralai_model).trigger('change');
1802 }1849 }
1803 }
18041850
1805 if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {1851 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
1806 $('#model_electronhub_select').empty();1856 $('#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
1817 const selectedModel = model_list.find(model => model.id === oai_settings.electronhub_model);1861 const selectedModel = model_list.find(model => model.id === oai_settings.electronhub_model);
1818 if (model_list.length > 0 && (!selectedModel || !oai_settings.electronhub_model)) {1862 if (model_list.length > 0 && (!selectedModel || !oai_settings.electronhub_model)) {
@@ -1984,6 +2028,24 @@ function saveModelList(data) {
1984 .append(new Option(modelId || 'None', modelId || '', true, true))2028 .append(new Option(modelId || 'None', modelId || '', true, true))
1985 .trigger('change');2029 .trigger('change');
1986 }2030 }
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 }
1987}2049}
19882050
1989function appendOpenRouterOptions(model_list, groupModels = false, sort = false) {2051function appendOpenRouterOptions(model_list, groupModels = false, sort = false) {
@@ -2041,6 +2103,61 @@ function openRouterGroupByVendor(array) {
2041 }, new Map());2103 }, new Map());
2042}2104}
20432105
2106function 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
2133function 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
2147function 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
2044function aimlapiGroupByVendor(array) {2161function aimlapiGroupByVendor(array) {
2045 return array.reduce((acc, curr) => {2162 return array.reduce((acc, curr) => {
2046 const vendor = curr.info.developer;2163 const vendor = curr.info.developer;
@@ -2191,7 +2308,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
2191 const useLogprobs = !!power_user.request_token_probabilities;2308 const useLogprobs = !!power_user.request_token_probabilities;
2192 const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isAzureOpenAI || isCustom || isXAI || isAimlapi || isMoonshot);2309 const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isAzureOpenAI || isCustom || isXAI || isAimlapi || isMoonshot);
21932310
2194 const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.CUSTOM];2311 const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.ELECTRONHUB, chat_completion_sources.CUSTOM];
2195 if (oai_settings.bias_preset_selected2312 if (oai_settings.bias_preset_selected
2196 && logitBiasSources.includes(oai_settings.chat_completion_source)2313 && logitBiasSources.includes(oai_settings.chat_completion_source)
2197 && Array.isArray(oai_settings.bias_presets[oai_settings.bias_preset_selected])2314 && Array.isArray(oai_settings.bias_presets[oai_settings.bias_preset_selected])
@@ -2353,19 +2470,24 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
2353 }2470 }
23542471
2355 if (isXAI) {2472 if (isXAI) {
2356 if (generate_data.model.includes('grok-4')) {2473 const model = generate_data.model;
2474 if (model.includes('grok-3-mini')) {
2357 delete generate_data.presence_penalty;2475 delete generate_data.presence_penalty;
2358 delete generate_data.frequency_penalty;2476 delete generate_data.frequency_penalty;
2359 delete generate_data.stop;2477 delete generate_data.stop;
2478 } else {
2479 // As of 2025/09/21, only grok-3-mini accepts reasoning_effort
2360 delete generate_data.reasoning_effort;2480 delete generate_data.reasoning_effort;
2361 }2481 }
2362 if (generate_data.model.includes('grok-3-mini')) {2482
2483 if (model.includes('grok-4') || model.includes('grok-code')) {
2363 delete generate_data.presence_penalty;2484 delete generate_data.presence_penalty;
2364 delete generate_data.frequency_penalty;2485 delete generate_data.frequency_penalty;
2486
2487 // grok-4-fast-non-reasoning accepts stop
2488 if (!model.includes('grok-4-fast-non-reasoning')) {
2489 delete generate_data.stop;
2365 }2490 }
2366 if (generate_data.model.includes('grok-vision')) {
2367 delete generate_data.tools;
2368 delete generate_data.tool_choice;
2369 }2491 }
2370 }2492 }
23712493
@@ -2559,7 +2681,7 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov
2559 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');2681 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
2560 }2682 }
2561 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';2683 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
2562 } 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].includes(chat_completion_source)) {2684 } 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)) {
2563 if (show_thoughts) {2685 if (show_thoughts) {
2564 state.reasoning +=2686 state.reasoning +=
2565 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??2687 data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
@@ -3523,6 +3645,8 @@ function loadOpenAISettings(data, settings) {
3523 oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;3645 oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;
3524 oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;3646 oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;
3525 oai_settings.electronhub_model = settings.electronhub_model ?? default_settings.electronhub_model;3647 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;
3526 oai_settings.nanogpt_model = settings.nanogpt_model ?? default_settings.nanogpt_model;3650 oai_settings.nanogpt_model = settings.nanogpt_model ?? default_settings.nanogpt_model;
3527 oai_settings.deepseek_model = settings.deepseek_model ?? default_settings.deepseek_model;3651 oai_settings.deepseek_model = settings.deepseek_model ?? default_settings.deepseek_model;
3528 oai_settings.aimlapi_model = settings.aimlapi_model ?? default_settings.aimlapi_model;3652 oai_settings.aimlapi_model = settings.aimlapi_model ?? default_settings.aimlapi_model;
@@ -3666,6 +3790,8 @@ function loadOpenAISettings(data, settings) {
3666 $('#openrouter_allow_fallbacks').prop('checked', oai_settings.openrouter_allow_fallbacks);3790 $('#openrouter_allow_fallbacks').prop('checked', oai_settings.openrouter_allow_fallbacks);
3667 $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change');3791 $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change');
3668 $('#openrouter_middleout').val(oai_settings.openrouter_middleout);3792 $('#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);
3669 $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages);3795 $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages);
3670 $('#continue_prefill').prop('checked', oai_settings.continue_prefill);3796 $('#continue_prefill').prop('checked', oai_settings.continue_prefill);
3671 $('#openai_function_calling').prop('checked', oai_settings.function_calling);3797 $('#openai_function_calling').prop('checked', oai_settings.function_calling);
@@ -3928,6 +4054,8 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
3928 pollinations_model: settings.pollinations_model,4054 pollinations_model: settings.pollinations_model,
3929 aimlapi_model: settings.aimlapi_model,4055 aimlapi_model: settings.aimlapi_model,
3930 electronhub_model: settings.electronhub_model,4056 electronhub_model: settings.electronhub_model,
4057 electronhub_sort_models: settings.electronhub_sort_models,
4058 electronhub_group_models: settings.electronhub_group_models,
3931 moonshot_model: settings.moonshot_model,4059 moonshot_model: settings.moonshot_model,
3932 fireworks_model: settings.fireworks_model,4060 fireworks_model: settings.fireworks_model,
3933 cometapi_model: settings.cometapi_model,4061 cometapi_model: settings.cometapi_model,
@@ -4532,75 +4660,8 @@ function getMistralMaxContext(model, isUnlocked) {
4532 }4660 }
4533 }4661 }
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
4602 // Return context size if model found, otherwise default to 32k4663 // 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;
4604}4665}
46054666
4606/**4667/**
@@ -4878,6 +4939,10 @@ async function onModelChange() {
4878 }4939 }
48794940
4880 if ($(this).is('#model_xai_select')) {4941 if ($(this).is('#model_xai_select')) {
4942 if (!value) {
4943 console.debug('Null XAI model selected. Ignoring.');
4944 return;
4945 }
4881 console.log('XAI model changed to', value);4946 console.log('XAI model changed to', value);
4882 oai_settings.xai_model = value;4947 oai_settings.xai_model = value;
4883 }4948 }
@@ -4916,17 +4981,15 @@ async function onModelChange() {
4916 if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(oai_settings.chat_completion_source)) {4981 if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(oai_settings.chat_completion_source)) {
4917 if (oai_settings.max_context_unlocked) {4982 if (oai_settings.max_context_unlocked) {
4918 $('#openai_max_context').attr('max', max_2mil);4983 $('#openai_max_context').attr('max', max_2mil);
4919 } else if (value.includes('gemini-1.5-pro')) {4984 } else if (value.includes('gemini-2.5-flash-image')) {
4920 $('#openai_max_context').attr('max', max_2mil);
4921 } else if (value.includes('gemini-2.5-flash-image-preview')) {
4922 $('#openai_max_context').attr('max', max_32k);4985 $('#openai_max_context').attr('max', max_32k);
4923 } 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')) {4986 } else if (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')) {
4924 $('#openai_max_context').attr('max', max_1mil);4987 $('#openai_max_context').attr('max', max_1mil);
4925 } else if (value.includes('gemma-3-27b-it')) {4988 } else if (value.includes('gemma-3-27b-it')) {
4926 $('#openai_max_context').attr('max', max_128k);4989 $('#openai_max_context').attr('max', max_128k);
4927 } else if (value.includes('gemma-3n-e4b-it')) {4990 } else if (value.includes('gemma-3n-e4b-it')) {
4928 $('#openai_max_context').attr('max', max_8k);4991 $('#openai_max_context').attr('max', max_8k);
4929 } else if (value.includes('gemma-3') || value.includes('learnlm-1.5-pro-experimental')) {4992 } else if (value.includes('gemma-3')) {
4930 $('#openai_max_context').attr('max', max_32k);4993 $('#openai_max_context').attr('max', max_32k);
4931 } else {4994 } else {
4932 $('#openai_max_context').attr('max', max_32k);4995 $('#openai_max_context').attr('max', max_32k);
@@ -4966,9 +5029,12 @@ async function onModelChange() {
49665029
4967 if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) {5030 if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) {
4968 if (oai_settings.max_context_unlocked) {5031 if (oai_settings.max_context_unlocked) {
4969 $('#openai_max_context').attr('max', max_200k);5032 $('#openai_max_context').attr('max', unlocked_max);
5033 }
5034 else if (value.startsWith('claude-sonnet-4-5')) {
5035 $('#openai_max_context').attr('max', max_1mil);
4970 }5036 }
4971 else if (value == 'claude-2.1' || value.startsWith('claude-3') || value.startsWith('claude-opus') || value.startsWith('claude-sonnet')) {5037 else if (value == 'claude-2.1' || value.startsWith('claude-3') || value.startsWith('claude-opus') || value.startsWith('claude-haiku') || value.startsWith('claude-sonnet')) {
4972 $('#openai_max_context').attr('max', max_200k);5038 $('#openai_max_context').attr('max', max_200k);
4973 }5039 }
4974 else if (value.endsWith('100k') || value.startsWith('claude-2') || value === 'claude-instant-1.2') {5040 else if (value.endsWith('100k') || value.startsWith('claude-2') || value === 'claude-instant-1.2') {
@@ -5093,6 +5159,8 @@ async function onModelChange() {
5093 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');5159 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
5094 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);5160 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
5095 $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');5161 $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
5162
5163 calculateElectronHubCost();
5096 }5164 }
50975165
5098 if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {5166 if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {
@@ -5144,11 +5212,14 @@ async function onModelChange() {
5144 $('#openai_max_context').attr('max', unlocked_max);5212 $('#openai_max_context').attr('max', unlocked_max);
5145 } else if (oai_settings.xai_model.includes('grok-2-vision')) {5213 } else if (oai_settings.xai_model.includes('grok-2-vision')) {
5146 $('#openai_max_context').attr('max', max_32k);5214 $('#openai_max_context').attr('max', max_32k);
5147 } else if (oai_settings.xai_model.includes('grok-vision')) {5215 } else if (oai_settings.xai_model.includes('grok-4-fast')) {
5148 $('#openai_max_context').attr('max', max_8k);5216 $('#openai_max_context').attr('max', max_2mil);
5149 } else if (oai_settings.xai_model.includes('grok-4')) {5217 } else if (oai_settings.xai_model.includes('grok-4')) {
5150 $('#openai_max_context').attr('max', max_256k);5218 $('#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);
5151 } else {5221 } else {
5222 // grok 2 and grok 3
5152 $('#openai_max_context').attr('max', max_128k);5223 $('#openai_max_context').attr('max', max_128k);
5153 }5224 }
51545225
@@ -5220,6 +5291,10 @@ async function onOpenrouterModelSortChange() {
5220 await getStatusOpen();5291 await getStatusOpen();
5221}5292}
52225293
5294async function onElectronHubModelSortChange() {
5295 await getStatusOpen();
5296}
5297
5223async function onNewPresetClick() {5298async function onNewPresetClick() {
5224 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);5299 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() {
5677 'c4ai-aya-vision',5752 'c4ai-aya-vision',
5678 'command-a-vision',5753 'command-a-vision',
5679 // Google AI Studio5754 // Google AI Studio
5680 'gemini-1.5',
5681 'gemini-2.0',5755 'gemini-2.0',
5682 'gemini-2.5',5756 'gemini-2.5',
5683 'gemini-exp-1206',5757 'gemini-exp-1206',
5684 'learnlm',5758 'learnlm',
5759 'gemini-robotics',
5685 // MistralAI5760 // MistralAI
5686 'mistral-small-2503',5761 'mistral-small-2503',
5687 'mistral-small-2506',5762 'mistral-small-2506',
@@ -5693,7 +5768,6 @@ export function isImageInliningSupported() {
5693 // xAI (Grok)5768 // xAI (Grok)
5694 'grok-4',5769 'grok-4',
5695 'grok-2-vision',5770 'grok-2-vision',
5696 'grok-vision',
5697 // Moonshot5771 // Moonshot
5698 'moonshot-v1-8k-vision-preview',5772 'moonshot-v1-8k-vision-preview',
5699 'moonshot-v1-32k-vision-preview',5773 'moonshot-v1-32k-vision-preview',
@@ -5726,6 +5800,7 @@ export function isImageInliningSupported() {
5726 case chat_completion_sources.COHERE:5800 case chat_completion_sources.COHERE:
5727 return visionSupportedModels.some(model => oai_settings.cohere_model.includes(model));5801 return visionSupportedModels.some(model => oai_settings.cohere_model.includes(model));
5728 case chat_completion_sources.XAI:5802 case chat_completion_sources.XAI:
5803 // TODO: xAI's /models endpoint doesn't return modality info
5729 return visionSupportedModels.some(model => oai_settings.xai_model.includes(model));5804 return visionSupportedModels.some(model => oai_settings.xai_model.includes(model));
5730 case chat_completion_sources.AIMLAPI:5805 case chat_completion_sources.AIMLAPI:
5731 return (Array.isArray(model_list) && model_list.find(m => m.id === oai_settings.aimlapi_model)?.features?.includes('openai/chat-completion.vision'));5806 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() {
6117 oai_settings.openai_max_context = Number($(this).val());6192 oai_settings.openai_max_context = Number($(this).val());
6118 $('#openai_max_context_counter').val(`${$(this).val()}`);6193 $('#openai_max_context_counter').val(`${$(this).val()}`);
6119 calculateOpenRouterCost();6194 calculateOpenRouterCost();
6195 calculateElectronHubCost();
6120 saveSettingsDebounced();6196 saveSettingsDebounced();
6121 });6197 });
61226198
6123 $('#openai_max_tokens').on('input', function () {6199 $('#openai_max_tokens').on('input', function () {
6124 oai_settings.openai_max_tokens = Number($(this).val());6200 oai_settings.openai_max_tokens = Number($(this).val());
6125 calculateOpenRouterCost();6201 calculateOpenRouterCost();
6202 calculateElectronHubCost();
6126 saveSettingsDebounced();6203 saveSettingsDebounced();
6127 });6204 });
61286205
@@ -6326,6 +6403,16 @@ export function initOpenAI() {
6326 saveSettingsDebounced();6403 saveSettingsDebounced();
6327 });6404 });
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
6329 $('#squash_system_messages').on('input', function () {6416 $('#squash_system_messages').on('input', function () {
6330 oai_settings.squash_system_messages = !!$(this).prop('checked');6417 oai_settings.squash_system_messages = !!$(this).prop('checked');
6331 saveSettingsDebounced();6418 saveSettingsDebounced();
@@ -6503,6 +6590,14 @@ export function initOpenAI() {
6503 width: '100%',6590 width: '100%',
6504 templateResult: getAimlapiModelTemplate,6591 templateResult: getAimlapiModelTemplate,
6505 });6592 });
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 });
6506 $('#completion_prompt_manager_popup_entry_form_injection_trigger').select2({6601 $('#completion_prompt_manager_popup_entry_form_injection_trigger').select2({
6507 placeholder: t`All types (default)`,6602 placeholder: t`All types (default)`,
6508 width: '100%',6603 width: '100%',
@@ -6549,6 +6644,8 @@ export function initOpenAI() {
6549 $('#model_openrouter_select').on('change', onModelChange);6644 $('#model_openrouter_select').on('change', onModelChange);
6550 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);6645 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);
6551 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);6646 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);
6647 $('#electronhub_group_models').on('change', onElectronHubModelSortChange);
6648 $('#electronhub_sort_models').on('change', onElectronHubModelSortChange);
6552 $('#model_ai21_select').on('change', onModelChange);6649 $('#model_ai21_select').on('change', onModelChange);
6553 $('#model_mistralai_select').on('change', onModelChange);6650 $('#model_mistralai_select').on('change', onModelChange);
6554 $('#model_cohere_select').on('change', onModelChange);6651 $('#model_cohere_select').on('change', onModelChange);
public/scripts/personas.js+39 -22
@@ -4,9 +4,11 @@ import {
4 characters,4 characters,
5 chat,5 chat,
6 chat_metadata,6 chat_metadata,
7 createOrEditCharacter,
7 default_user_avatar,8 default_user_avatar,
8 eventSource,9 eventSource,
9 event_types,10 event_types,
11 getCurrentChatId,
10 getRequestHeaders,12 getRequestHeaders,
11 getThumbnailUrl,13 getThumbnailUrl,
12 groupToEntity,14 groupToEntity,
@@ -69,6 +71,9 @@ export let user_avatar = '';
69/** @type {FilterHelper} Filter helper for the persona list */71/** @type {FilterHelper} Filter helper for the persona list */
70export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));72export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
7173
74/** @type {string} The last loaded chat id to remember for persona loading */
75let personaLastLoadedChatId = null;
76
72/** @type {function(string): void} */77/** @type {function(string): void} */
73let navigateToAvatar = () => { };78let navigateToAvatar = () => { };
7479
@@ -107,12 +112,16 @@ export function initUserAvatar(avatar) {
107 * @param {boolean} [options.toastPersonaNameChange=true] Whether to show a toast when the persona name is changed112 * @param {boolean} [options.toastPersonaNameChange=true] Whether to show a toast when the persona name is changed
108 * @param {boolean} [options.navigateToCurrent=false] Whether to navigate to the current persona after setting the avatar113 * @param {boolean} [options.navigateToCurrent=false] Whether to navigate to the current persona after setting the avatar
109 */114 */
110export function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) {115export async function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) {
116 const currentUserAvatar = user_avatar;
111 user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id');117 user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id');
118 if (currentUserAvatar === user_avatar) {
119 return;
120 }
112 reloadUserAvatar();121 reloadUserAvatar();
113 updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });122 updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });
114 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });123 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
115 retriggerFirstMessageOnEmptyChat();124 await retriggerFirstMessageOnEmptyChat();
116 saveSettingsDebounced();125 saveSettingsDebounced();
117 $('.zoomed_avatar[forchar]').remove();126 $('.zoomed_avatar[forchar]').remove();
118}127}
@@ -732,13 +741,13 @@ export async function askForPersonaSelection(title, text, personas, { okButton =
732/**741/**
733 * Automatically selects a persona based on the given name if a matching persona exists.742 * Automatically selects a persona based on the given name if a matching persona exists.
734 * @param {string} name - The name to search for743 * @param {string} name - The name to search for
735 * @returns {boolean} True if a matching persona was found and selected, false otherwise744 * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise
736 */745 */
737export function autoSelectPersona(name) {746export async function autoSelectPersona(name) {
738 for (const [key, value] of Object.entries(power_user.personas)) {747 for (const [key, value] of Object.entries(power_user.personas)) {
739 if (value === name) {748 if (value === name) {
740 console.log(`Auto-selecting persona ${key} for name ${name}`);749 console.log(`Auto-selecting persona ${key} for name ${name}`);
741 setUserAvatar(key);750 await setUserAvatar(key);
742 return true;751 return true;
743 }752 }
744 }753 }
@@ -1131,7 +1140,7 @@ function onPersonaDescriptionInput() {
1131 object.description = power_user.persona_description;1140 object.description = power_user.persona_description;
1132 }1141 }
11331142
1134 $(`.avatar-container[imgfile="${user_avatar}"] .ch_description`)1143 $(`.avatar-container[data-avatar-id="${user_avatar}"] .ch_description`)
1135 .text(power_user.persona_description || $('#user_avatar_block').attr('no_desc_text'))1144 .text(power_user.persona_description || $('#user_avatar_block').attr('no_desc_text'))
1136 .toggleClass('text_muted', !power_user.persona_description);1145 .toggleClass('text_muted', !power_user.persona_description);
1137 saveSettingsDebounced();1146 saveSettingsDebounced();
@@ -1219,7 +1228,7 @@ function onPersonaDescriptionPositionInput() {
1219 $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);1228 $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
1220}1229}
12211230
1222function getOrCreatePersonaDescriptor() {1231export function getOrCreatePersonaDescriptor() {
1223 let object = power_user.persona_descriptions[user_avatar];1232 let object = power_user.persona_descriptions[user_avatar];
12241233
1225 if (!object) {1234 if (!object) {
@@ -1429,13 +1438,17 @@ function getPersonaTemporaryLockInfo() {
1429 * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether a persona was selected1438 * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether a persona was selected
1430 */1439 */
1431async function loadPersonaForCurrentChat({ doRender = false } = {}) {1440async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1441 const currentChatId = getCurrentChatId();
1442 if (currentChatId === personaLastLoadedChatId) return;
1443 personaLastLoadedChatId = currentChatId;
1444
1432 // Cache persona list to check if they exist1445 // Cache persona list to check if they exist
1433 const userAvatars = await getUserAvatars(doRender);1446 const userAvatars = await getUserAvatars(doRender);
14341447
1435 // Check if the user avatar is set and exists in the list of user avatars1448 // Check if the user avatar is set and exists in the list of user avatars
1436 if (userAvatars.length && !userAvatars.includes(user_avatar)) {1449 if (userAvatars.length && !userAvatars.includes(user_avatar)) {
1437 console.log(`User avatar ${user_avatar} not found in user avatars list, pick the first available one`);1450 console.log(`User avatar ${user_avatar} not found in user avatars list, pick the first available one`);
1438 setUserAvatar(userAvatars[0], { toastPersonaNameChange: false, navigateToCurrent: true });1451 await setUserAvatar(userAvatars[0], { toastPersonaNameChange: false, navigateToCurrent: true });
1439 }1452 }
14401453
1441 // Define a persona for this chat1454 // Define a persona for this chat
@@ -1530,7 +1543,7 @@ async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1530 // Persona avatar found, select it1543 // Persona avatar found, select it
1531 if (chatPersona && user_avatar !== chatPersona) {1544 if (chatPersona && user_avatar !== chatPersona) {
1532 const willAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata['persona'];1545 const willAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata['persona'];
1533 setUserAvatar(chatPersona, { toastPersonaNameChange: false, navigateToCurrent: true });1546 await setUserAvatar(chatPersona, { toastPersonaNameChange: false, navigateToCurrent: true });
15341547
1535 if (power_user.persona_show_notifications) {1548 if (power_user.persona_show_notifications) {
1536 let message = t`Auto-selected persona based on ${connectType} connection.<br />Your messages will now be sent as ${power_user.personas[chatPersona]}.`;1549 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 } = {}) {
1542 }1555 }
1543 // Even if it's the same persona, we still might need to auto-lock to chat if that's enabled1556 // Even if it's the same persona, we still might need to auto-lock to chat if that's enabled
1544 else if (chatPersona && power_user.persona_auto_lock && !chat_metadata['persona']) {1557 else if (chatPersona && power_user.persona_auto_lock && !chat_metadata['persona']) {
1545 lockPersona('chat');1558 await lockPersona('chat');
1546 }1559 }
15471560
1548 updatePersonaUIStates();1561 updatePersonaUIStates();
@@ -1559,7 +1572,7 @@ async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1559export function getConnectedPersonas(characterKey = undefined) {1572export function getConnectedPersonas(characterKey = undefined) {
1560 characterKey ??= selected_group || characters[Number(this_chid)]?.avatar;1573 characterKey ??= selected_group || characters[Number(this_chid)]?.avatar;
1561 const connectedPersonas = Object.entries(power_user.persona_descriptions)1574 const connectedPersonas = Object.entries(power_user.persona_descriptions)
1562 .filter(([_, desc]) => desc.connections?.some(conn => conn.type === 'character' && conn.id === characterKey))1575 .filter(([_, { connections }]) => connections?.some(conn => conn.id === characterKey))
1563 .map(([key, _]) => key);1576 .map(([key, _]) => key);
1564 return connectedPersonas;1577 return connectedPersonas;
1565}1578}
@@ -1606,7 +1619,7 @@ export async function showCharConnections() {
16061619
1607 // One of the persona was selected. So load it.1620 // One of the persona was selected. So load it.
1608 if (!isRemoving && selectedPersona) {1621 if (!isRemoving && selectedPersona) {
1609 setUserAvatar(selectedPersona, { toastPersonaNameChange: false });1622 await setUserAvatar(selectedPersona, { toastPersonaNameChange: false });
1610 if (power_user.persona_show_notifications) {1623 if (power_user.persona_show_notifications) {
1611 toastr.success(t`Selected persona ${power_user.personas[selectedPersona]} for current chat.`, t`Connected Persona Selected`);1624 toastr.success(t`Selected persona ${power_user.personas[selectedPersona]} for current chat.`, t`Connected Persona Selected`);
1612 }1625 }
@@ -1736,12 +1749,16 @@ async function syncUserNameToPersona() {
17361749
1737/**1750/**
1738 * Retriggers the first message to reload it from the char definition.1751 * 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.
1741 */1752 */
1742export function retriggerFirstMessageOnEmptyChat() {1753export 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();
1745 }1762 }
1746}1763}
17471764
@@ -1838,9 +1855,9 @@ async function lockPersonaCallback(_args, value) {
1838 * Sets a persona name and optionally an avatar.1855 * Sets a persona name and optionally an avatar.
1839 * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments1856 * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments
1840 * @param {string} name Name to set1857 * @param {string} name Name to set
1841 * @returns {string}1858 * @returns {Promise<string>}
1842 */1859 */
1843function setNameCallback({ mode = 'all' }, name) {1860async function setNameCallback({ mode = 'all' }, name) {
1844 if (!name) {1861 if (!name) {
1845 toastr.warning('You must specify a name to change to');1862 toastr.warning('You must specify a name to change to');
1846 return '';1863 return '';
@@ -1858,7 +1875,7 @@ function setNameCallback({ mode = 'all' }, name) {
1858 let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1];1875 let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1];
1859 if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];1876 if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];
1860 if (persona) {1877 if (persona) {
1861 autoSelectPersona(persona);1878 await autoSelectPersona(persona);
1862 return '';1879 return '';
1863 } else if (mode === 'lookup') {1880 } else if (mode === 'lookup') {
1864 toastr.warning(`Persona ${name} not found`);1881 toastr.warning(`Persona ${name} not found`);
@@ -2016,9 +2033,9 @@ export async function initPersonas() {
2016 $('#sync_name_button').on('click', syncUserNameToPersona);2033 $('#sync_name_button').on('click', syncUserNameToPersona);
2017 $('#avatar_upload_file').on('change', changeUserAvatar);2034 $('#avatar_upload_file').on('change', changeUserAvatar);
20182035
2019 $(document).on('click', '#user_avatar_block .avatar-container', function () {2036 $(document).on('click', '#user_avatar_block .avatar-container', async function () {
2020 const imgfile = $(this).attr('data-avatar-id');2037 const imgfile = $(this).attr('data-avatar-id');
2021 setUserAvatar(imgfile);2038 await setUserAvatar(imgfile);
2022 });2039 });
20232040
2024 $('#persona_rename_button').on('click', () => renamePersona(user_avatar));2041 $('#persona_rename_button').on('click', () => renamePersona(user_avatar));
public/scripts/popup.js+18 -4
@@ -37,8 +37,8 @@ export const POPUP_RESULT = {
3737
38/**38/**
39 * @typedef {object} PopupOptions39 * @typedef {object} PopupOptions
40 * @property {string|boolean?} [okButton=null] - Custom text for the OK button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup)40 * @property {string|boolean?} [okButton=null] - Custom text for the OK button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type.
41 * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup)41 * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type.
42 * @property {number?} [rows=1] - The number of rows for the input field42 * @property {number?} [rows=1] - The number of rows for the input field
43 * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)43 * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)
44 * @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling)44 * @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
327 switch (type) {327 switch (type) {
328 case POPUP_TYPE.TEXT: {328 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';
329 if (!cancelButton) this.cancelButton.style.display = 'none';331 if (!cancelButton) this.cancelButton.style.display = 'none';
330 break;332 break;
331 }333 }
332 case POPUP_TYPE.CONFIRM: {334 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
333 if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes');339 if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes');
334 if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no');340 if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no');
335 break;341 break;
336 }342 }
337 case POPUP_TYPE.INPUT: {343 case POPUP_TYPE.INPUT: {
338 this.mainInput.style.display = 'block';344 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';
340 if (cancelButton === false) this.cancelButton.style.display = 'none';347 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');
341 break;350 break;
342 }351 }
343 case POPUP_TYPE.DISPLAY: {352 case POPUP_TYPE.DISPLAY: {
353 // Display hides OK and CANCEL and all main button controls
344 this.buttonControls.style.display = 'none';354 this.buttonControls.style.display = 'none';
345 this.closeButton.style.display = 'block';355 this.closeButton.style.display = 'block';
346 break;356 break;
@@ -348,7 +358,6 @@ export class Popup {
348 case POPUP_TYPE.CROP: {358 case POPUP_TYPE.CROP: {
349 this.cropWrap.style.display = 'block';359 this.cropWrap.style.display = 'block';
350 this.cropImage.src = cropImage;360 this.cropImage.src = cropImage;
351 if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop');
352 $(this.cropImage).cropper({361 $(this.cropImage).cropper({
353 aspectRatio: cropAspect ?? 2 / 3,362 aspectRatio: cropAspect ?? 2 / 3,
354 autoCropArea: 1,363 autoCropArea: 1,
@@ -359,6 +368,11 @@ export class Popup {
359 this.cropData.want_resize = !power_user.never_resize_avatars;368 this.cropData.want_resize = !power_user.never_resize_avatars;
360 },369 },
361 });370 });
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');
362 break;376 break;
363 }377 }
364 default: {378 default: {
public/scripts/power-user.js+8 -0
@@ -137,6 +137,7 @@ export const power_user = {
137 streaming_fps: 30,137 streaming_fps: 30,
138 smooth_streaming: false,138 smooth_streaming: false,
139 smooth_streaming_speed: 50,139 smooth_streaming_speed: 50,
140 stream_fade_in: false,
140141
141 fast_ui_mode: true,142 fast_ui_mode: true,
142 avatar_style: avatar_styles.ROUND,143 avatar_style: avatar_styles.ROUND,
@@ -1685,6 +1686,8 @@ export async function loadPowerUserSettings(settings, data) {
1685 $('#smooth_streaming').prop('checked', power_user.smooth_streaming);1686 $('#smooth_streaming').prop('checked', power_user.smooth_streaming);
1686 $('#smooth_streaming_speed').val(power_user.smooth_streaming_speed);1687 $('#smooth_streaming_speed').val(power_user.smooth_streaming_speed);
16871688
1689 $('#stream_fade_in').prop('checked', power_user.stream_fade_in);
1690
1688 $('#font_scale').val(power_user.font_scale);1691 $('#font_scale').val(power_user.font_scale);
1689 $('#font_scale_counter').val(power_user.font_scale);1692 $('#font_scale_counter').val(power_user.font_scale);
16901693
@@ -3493,6 +3496,11 @@ jQuery(() => {
3493 saveSettingsDebounced();3496 saveSettingsDebounced();
3494 });3497 });
34953498
3499 $('#stream_fade_in').on('input', function () {
3500 power_user.stream_fade_in = !!$(this).prop('checked');
3501 saveSettingsDebounced();
3502 });
3503
3496 $('input[name="font_scale"]').on('input', async function (e, data) {3504 $('input[name="font_scale"]').on('input', async function (e, data) {
3497 const applyMode = data?.forced ? 'forced' : 'normal';3505 const applyMode = data?.forced ? 'forced' : 'normal';
3498 power_user.font_scale = Number($(this).val());3506 power_user.font_scale = Number($(this).val());
public/scripts/preset-manager.js+14 -10
@@ -81,6 +81,9 @@ function autoSelectPreset() {
81 * @returns {PresetManager} Preset manager81 * @returns {PresetManager} Preset manager
82 */82 */
83export function getPresetManager(apiId = '') {83export function getPresetManager(apiId = '') {
84 if (apiId === 'koboldhorde') {
85 apiId = 'kobold';
86 }
84 if (!apiId) {87 if (!apiId) {
85 apiId = main_api == 'koboldhorde' ? 'kobold' : main_api;88 apiId = main_api == 'koboldhorde' ? 'kobold' : main_api;
86 }89 }
@@ -814,7 +817,7 @@ class PresetManager {
814 * Reads a preset extension field from the preset.817 * Reads a preset extension field from the preset.
815 * @param {object} options818 * @param {object} options
816 * @param {string} [options.name] Name of the preset. If not provided, uses the currently selected preset name.819 * @param {string} [options.name] Name of the preset. If not provided, uses the currently selected preset name.
817 * @param {string} options.path Path to the preset extension field, e.g. 'myextension.data'.820 * @param {string} options.path Path to the preset extension field, e.g. 'myextension.data'. If empty, reads the entire extensions object.
818 * @return {any} The value of the preset extension field, or null if not found.821 * @return {any} The value of the preset extension field, or null if not found.
819 */822 */
820 readPresetExtensionField({ name, path }) {823 readPresetExtensionField({ name, path }) {
@@ -825,7 +828,7 @@ class PresetManager {
825 // Read from settings if the selected preset is the same as the provided name828 // Read from settings if the selected preset is the same as the provided name
826 if (settings && selectedName === presetName) {829 if (settings && selectedName === presetName) {
827 const settingsExtensions = ensurePlainObject(settings.extensions || {});830 const settingsExtensions = ensurePlainObject(settings.extensions || {});
828 return lodash.get(settingsExtensions, path, null);831 return path ? lodash.get(settingsExtensions, path, null) : settingsExtensions;
829 }832 }
830833
831 // Otherwise, read from the preset by name834 // Otherwise, read from the preset by name
@@ -835,7 +838,7 @@ class PresetManager {
835 }838 }
836839
837 const presetExtensions = ensurePlainObject(preset.extensions || {});840 const presetExtensions = ensurePlainObject(preset.extensions || {});
838 const value = lodash.get(presetExtensions, path, null);841 const value = path ? lodash.get(presetExtensions, path, null) : presetExtensions;
839 return value;842 return value;
840 }843 }
841844
@@ -843,7 +846,7 @@ class PresetManager {
843 * Writes a value to a preset extension field.846 * Writes a value to a preset extension field.
844 * @param {object} options847 * @param {object} options
845 * @param {string} [options.name] Name of the preset. If not provided, uses the currently selected preset name.848 * @param {string} [options.name] Name of the preset. If not provided, uses the currently selected preset name.
846 * @param {string} options.path Path to the preset extension field, e.g. 'myextension.data'.849 * @param {string} options.path Path to the preset extension field, e.g. 'myextension.data'. If empty, writes to the root of the extensions object.
847 * @param {any} options.value Value to write to the preset extension field.850 * @param {any} options.value Value to write to the preset extension field.
848 * @return {Promise<void>} Resolves when the preset is saved.851 * @return {Promise<void>} Resolves when the preset is saved.
849 */852 */
@@ -856,7 +859,7 @@ class PresetManager {
856 if (settings && selectedName === presetName) {859 if (settings && selectedName === presetName) {
857 // Set the value at the specified path860 // Set the value at the specified path
858 settings.extensions = ensurePlainObject(settings.extensions || {});861 settings.extensions = ensurePlainObject(settings.extensions || {});
859 lodash.set(settings.extensions, path, value);862 path ? lodash.set(settings.extensions, path, value) : (settings.extensions = value);
860 await saveSettings();863 await saveSettings();
861 }864 }
862865
@@ -868,7 +871,7 @@ class PresetManager {
868871
869 // Set the value at the specified path872 // Set the value at the specified path
870 preset.extensions = ensurePlainObject(preset.extensions || {});873 preset.extensions = ensurePlainObject(preset.extensions || {});
871 lodash.set(preset.extensions, path, value);874 path ? lodash.set(preset.extensions, path, value) : (preset.extensions = value);
872875
873 // Save the updated preset876 // Save the updated preset
874 await this.savePreset(presetName, preset, { skipUpdate: true });877 await this.savePreset(presetName, preset, { skipUpdate: true });
@@ -1033,10 +1036,11 @@ export async function initPresetManager() {
1033 return;1036 return;
1034 }1037 }
10351038
1039 await eventSource.emit(event_types.PRESET_RENAMED_BEFORE, { apiId: apiId, oldName: oldName, newName: newName });
1040 const extensions = presetManager.readPresetExtensionField({ name: oldName, path: '' });
1036 await presetManager.renamePreset(newName);1041 await presetManager.renamePreset(newName);
10371042 await presetManager.writePresetExtensionField({ name: newName, path: '', value: extensions });
1038 await eventSource.emit(event_types.PRESET_DELETED, { apiId: apiId, name: oldName });1043 await eventSource.emit(event_types.PRESET_RENAMED, { apiId: apiId, oldName: oldName, newName: newName });
1039 await eventSource.emit(event_types.PRESET_CHANGED, { apiId: apiId, name: newName });
10401044
1041 if (apiId === 'openai') {1045 if (apiId === 'openai') {
1042 // This is a horrible mess, but prevents the renamed preset from being corrupted.1046 // This is a horrible mess, but prevents the renamed preset from being corrupted.
@@ -1116,13 +1120,13 @@ export async function initPresetManager() {
1116 if (result) {1120 if (result) {
1117 const successToast = !presetManager.isAdvancedFormatting() ? t`Preset deleted` : t`Template deleted`;1121 const successToast = !presetManager.isAdvancedFormatting() ? t`Preset deleted` : t`Template deleted`;
1118 toastr.success(successToast);1122 toastr.success(successToast);
1123 await eventSource.emit(event_types.PRESET_DELETED, { apiId, name });
1119 } else {1124 } else {
1120 const warningToast = !presetManager.isAdvancedFormatting() ? t`Preset was not deleted from server` : t`Template was not deleted from server`;1125 const warningToast = !presetManager.isAdvancedFormatting() ? t`Preset was not deleted from server` : t`Template was not deleted from server`;
1121 toastr.warning(warningToast);1126 toastr.warning(warningToast);
1122 }1127 }
11231128
1124 saveSettingsDebounced();1129 saveSettingsDebounced();
1125 await eventSource.emit(event_types.PRESET_DELETED, { apiId: apiId, name: name });
1126 });1130 });
11271131
1128 $(document).on('click', '[data-preset-manager-restore]', async function () {1132 $(document).on('click', '[data-preset-manager-restore]', async function () {
public/scripts/reasoning.js+8 -0
@@ -15,6 +15,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
15import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';15import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
16import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';16import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
17import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';17import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
18import { applyStreamFadeIn } from './util/stream-fadein.js';
18import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js';19import { copyText, escapeRegex, isFalseBoolean, isTrueBoolean, setDatasetProperty, trimSpaces } from './utils.js';
1920
20/**21/**
@@ -124,6 +125,8 @@ export function extractReasoningFromData(data, {
124 case chat_completion_sources.POLLINATIONS:125 case chat_completion_sources.POLLINATIONS:
125 case chat_completion_sources.MOONSHOT:126 case chat_completion_sources.MOONSHOT:
126 case chat_completion_sources.COMETAPI:127 case chat_completion_sources.COMETAPI:
128 case chat_completion_sources.ELECTRONHUB:
129 case chat_completion_sources.NANOGPT:
127 case chat_completion_sources.CUSTOM: {130 case chat_completion_sources.CUSTOM: {
128 return data?.choices?.[0]?.message?.reasoning_content131 return data?.choices?.[0]?.message?.reasoning_content
129 ?? data?.choices?.[0]?.message?.reasoning132 ?? data?.choices?.[0]?.message?.reasoning
@@ -495,7 +498,12 @@ export class ReasoningHandler {
495 // Update the reasoning message498 // Update the reasoning message
496 const reasoning = trimSpaces(this.reasoningDisplayText ?? this.reasoning);499 const reasoning = trimSpaces(this.reasoningDisplayText ?? this.reasoning);
497 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);500 const displayReasoning = messageFormatting(reasoning, '', false, false, messageId, {}, true);
501
502 if (power_user.stream_fade_in) {
503 applyStreamFadeIn(this.messageReasoningContentDom, displayReasoning);
504 } else {
498 this.messageReasoningContentDom.innerHTML = displayReasoning;505 this.messageReasoningContentDom.innerHTML = displayReasoning;
506 }
499507
500 // Update tooltip for hidden reasoning edit508 // Update tooltip for hidden reasoning edit
501 /** @type {HTMLElement} */509 /** @type {HTMLElement} */
public/scripts/secrets.js+25 -7
@@ -2,7 +2,7 @@ import { DOMPurify, moment } from '../lib.js';
2import { event_types, eventSource, getRequestHeaders } from '../script.js';2import { event_types, eventSource, getRequestHeaders } from '../script.js';
3import { t } from './i18n.js';3import { t } from './i18n.js';
4import { chat_completion_sources } from './openai.js';4import { chat_completion_sources } from './openai.js';
5import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
6import { SlashCommand } from './slash-commands/SlashCommand.js';6import { SlashCommand } from './slash-commands/SlashCommand.js';
7import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';7import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
8import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';8import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -293,11 +293,13 @@ export let secret_state = {};
293 * @param {string} key Secret key293 * @param {string} key Secret key
294 * @param {string} value Secret value to write294 * @param {string} value Secret value to write
295 * @param {string} [label] (Optional) Label for the key. If not provided, generated automatically.295 * @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.
296 * @return {Promise<string?>} The ID of the newly created secret key, or null if no value is provided.298 * @return {Promise<string?>} The ID of the newly created secret key, or null if no value is provided.
297 */299 */
298export async function writeSecret(key, value, label) {300export async function writeSecret(key, value, label, { allowEmpty } = {}) {
299 try {301 try {
300 if (!value) {302 if (!value && !allowEmpty) {
301 console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);303 console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);
302 await deleteSecret(key);304 await deleteSecret(key);
303 return null;305 return null;
@@ -558,7 +560,8 @@ async function openKeyManagerDialog(key) {
558 const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));560 const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));
559 template.find('button[data-action="add-secret"]').on('click', async function () {561 template.find('button[data-action="add-secret"]').on('click', async function () {
560 let label = '';562 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):`, '', {
562 customInputs: [{565 customInputs: [{
563 id: 'newSecretLabel',566 id: 'newSecretLabel',
564 type: 'text',567 type: 'text',
@@ -567,13 +570,20 @@ async function openKeyManagerDialog(key) {
567 onClose: popup => {570 onClose: popup => {
568 if (popup.result) {571 if (popup.result) {
569 label = popup.inputResults.get('newSecretLabel').toString().trim();572 label = popup.inputResults.get('newSecretLabel').toString().trim();
573 result = popup.result;
570 }574 }
571 },575 },
572 });576 });
573 if (!value) {577 if (!value) {
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) {
574 return;583 return;
575 }584 }
576 await writeSecret(key, value, label);585 }
586 await writeSecret(key, value, label, { allowEmpty: true });
577 await renderSecretsList();587 await renderSecretsList();
578 });588 });
579589
@@ -821,6 +831,13 @@ function registerSecretSlashCommands() {
821 isRequired: false,831 isRequired: false,
822 typeList: [ARGUMENT_TYPE.STRING],832 typeList: [ARGUMENT_TYPE.STRING],
823 }),833 }),
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 }),
824 ],841 ],
825 unnamedArgumentList: [842 unnamedArgumentList: [
826 SlashCommandArgument.fromProps({843 SlashCommandArgument.fromProps({
@@ -831,6 +848,7 @@ function registerSecretSlashCommands() {
831 ],848 ],
832 callback: async (args, value) => {849 callback: async (args, value) => {
833 const quiet = isTrueBoolean(args?.quiet?.toString());850 const quiet = isTrueBoolean(args?.quiet?.toString());
851 const allowEmpty = isTrueBoolean(args?.empty?.toString());
834 const key = args?.key?.toString()?.trim() || resolveSecretKey();852 const key = args?.key?.toString()?.trim() || resolveSecretKey();
835853
836 if (!key) {854 if (!key) {
@@ -849,7 +867,7 @@ function registerSecretSlashCommands() {
849 }867 }
850868
851 const valueStr = value?.toString()?.trim();869 const valueStr = value?.toString()?.trim();
852 if (!valueStr) {870 if (!valueStr && !allowEmpty) {
853 if (!quiet) {871 if (!quiet) {
854 toastr.error(t`No value provided for the secret key: ${key}`);872 toastr.error(t`No value provided for the secret key: ${key}`);
855 }873 }
@@ -857,7 +875,7 @@ function registerSecretSlashCommands() {
857 }875 }
858876
859 const label = args?.label?.toString()?.trim() || getLabel();877 const label = args?.label?.toString()?.trim() || getLabel();
860 const id = await writeSecret(key, valueStr, label);878 const id = await writeSecret(key, valueStr, label, { allowEmpty });
861879
862 if (!quiet) {880 if (!quiet) {
863 toastr.success(t`Secret has been written for the key: ${key}`);881 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) {
4585 insertAt = chat.length + insertAt;4585 insertAt = chat.length + insertAt;
4586 }4586 }
45874587
4588 chat_metadata['tainted'] = true;
4589
4588 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {4590 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {
4589 chat.splice(insertAt, 0, message);4591 chat.splice(insertAt, 0, message);
4590 await saveChatConditional();4592 await saveChatConditional();
@@ -4635,6 +4637,8 @@ export async function sendNarratorMessage(args, text) {
4635 insertAt = chat.length + insertAt;4637 insertAt = chat.length + insertAt;
4636 }4638 }
46374639
4640 chat_metadata['tainted'] = true;
4641
4638 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {4642 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {
4639 chat.splice(insertAt, 0, message);4643 chat.splice(insertAt, 0, message);
4640 await saveChatConditional();4644 await saveChatConditional();
@@ -4685,6 +4689,8 @@ export async function promptQuietForLoudResponse(who, text) {
4685 },4689 },
4686 };4690 };
46874691
4692 chat_metadata['tainted'] = true;
4693
4688 chat.push(message);4694 chat.push(message);
4689 await eventSource.emit(event_types.MESSAGE_SENT, (chat.length - 1));4695 await eventSource.emit(event_types.MESSAGE_SENT, (chat.length - 1));
4690 addOneMessage(message);4696 addOneMessage(message);
@@ -4719,6 +4725,8 @@ async function sendCommentMessage(args, text) {
4719 insertAt = chat.length + insertAt;4725 insertAt = chat.length + insertAt;
4720 }4726 }
47214727
4728 chat_metadata['tainted'] = true;
4729
4722 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {4730 if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) {
4723 chat.splice(insertAt, 0, message);4731 chat.splice(insertAt, 0, message);
4724 await saveChatConditional();4732 await saveChatConditional();
public/scripts/st-context.js+2 -0
@@ -57,6 +57,7 @@ import {
57import {57import {
58 extension_settings,58 extension_settings,
59 ModuleWorkerWrapper,59 ModuleWorkerWrapper,
60 openThirdPartyExtensionMenu,
60 renderExtensionTemplate,61 renderExtensionTemplate,
61 renderExtensionTemplateAsync,62 renderExtensionTemplateAsync,
62 saveMetadataDebounced,63 saveMetadataDebounced,
@@ -233,6 +234,7 @@ export function getContext() {
233 parseReasoningFromString,234 parseReasoningFromString,
234 unshallowCharacter,235 unshallowCharacter,
235 unshallowGroupMembers,236 unshallowGroupMembers,
237 openThirdPartyExtensionMenu,
236 symbols: {238 symbols: {
237 ignore: IGNORE_SYMBOL,239 ignore: IGNORE_SYMBOL,
238 },240 },
public/scripts/tags.js+5 -0
@@ -765,8 +765,13 @@ async function importTags(character, { importSetting = null } = {}) {
765 */765 */
766async function handleTagImport(character, { importSetting = null } = {}) {766async function handleTagImport(character, { importSetting = null } = {}) {
767 /** @type {string[]} */767 /** @type {string[]} */
768 const alreadyAssignedTags = tag_map[character.avatar] ?? [];
768 const importTags = character.tags.map(t => t.trim()).filter(t => t)769 const importTags = character.tags.map(t => t.trim()).filter(t => t)
769 .filter(t => !IMPORT_EXLCUDED_TAGS.includes(t))770 .filter(t => !IMPORT_EXLCUDED_TAGS.includes(t))
771 .filter(t => {
772 const existingTag = getTag(t);
773 return !existingTag || !alreadyAssignedTags.includes(existingTag.id);
774 })
770 .slice(0, ANTI_TROLL_MAX_TAGS);775 .slice(0, ANTI_TROLL_MAX_TAGS);
771 const existingTags = getExistingTags(importTags);776 const existingTags = getExistingTags(importTags);
772 const newTags = importTags.filter(t => !existingTags.some(existingTag => existingTag.name.toLowerCase() === t.toLowerCase()))777 const newTags = importTags.filter(t => !existingTags.some(existingTag => existingTag.name.toLowerCase() === t.toLowerCase()))
public/scripts/templates/macros.html+2 -0
@@ -22,8 +22,10 @@
22 <li><tt>&lcub;&lcub;char&rcub;&rcub;</tt> – <span data-i18n="help_macros_16">the Character's name</span></li>22 <li><tt>&lcub;&lcub;char&rcub;&rcub;</tt> – <span data-i18n="help_macros_16">the Character's name</span></li>
23 <li><tt>&lcub;&lcub;version&rcub;&rcub;</tt> – <span data-i18n="help_macros_17">the Character's version number</span></li>23 <li><tt>&lcub;&lcub;version&rcub;&rcub;</tt> – <span data-i18n="help_macros_17">the Character's version number</span></li>
24 <li><tt>&lcub;&lcub;charDepthPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_charDepthPrompt">the Character's @ Depth Note</span></li>24 <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>
25 <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>26 <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>
26 <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>27 <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>
27 <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>29 <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>
28 <li><tt>&lcub;&lcub;lastMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_20">the text of the latest chat message.</span></li>30 <li><tt>&lcub;&lcub;lastMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_20">the text of the latest chat message.</span></li>
29 <li><tt>&lcub;&lcub;lastUserMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_lastUser">the text of the latest user chat message.</span></li>31 <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() {
648 }648 }
649 }649 }
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
651 if (oai_settings.chat_completion_source == chat_completion_sources.COHERE) {702 if (oai_settings.chat_completion_source == chat_completion_sources.COHERE) {
652 if (oai_settings.cohere_model.includes('command-a')) {703 if (oai_settings.cohere_model.includes('command-a')) {
653 return commandATokenizer;704 return commandATokenizer;
public/scripts/tool-calling.js+3 -2
@@ -526,12 +526,13 @@ export class ToolManager {
526 for (let choiceIndex = 0; choiceIndex < parsed.candidates.length; choiceIndex++) {526 for (let choiceIndex = 0; choiceIndex < parsed.candidates.length; choiceIndex++) {
527 const candidate = parsed.candidates[choiceIndex];527 const candidate = parsed.candidates[choiceIndex];
528 if (Array.isArray(candidate?.content?.parts)) {528 if (Array.isArray(candidate?.content?.parts)) {
529 for (let toolCallIndex = 0; toolCallIndex < candidate.content.parts.length; toolCallIndex++) {529 for (let partIndex = 0; partIndex < candidate.content.parts.length; partIndex++) {
530 const part = candidate.content.parts[toolCallIndex];530 const part = candidate.content.parts[partIndex];
531 if (part.functionCall) {531 if (part.functionCall) {
532 if (!Array.isArray(toolCalls[choiceIndex])) {532 if (!Array.isArray(toolCalls[choiceIndex])) {
533 toolCalls[choiceIndex] = [];533 toolCalls[choiceIndex] = [];
534 }534 }
535 const toolCallIndex = toolCalls[choiceIndex].length;
535 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {536 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
536 toolCalls[choiceIndex][toolCallIndex] = {};537 toolCalls[choiceIndex][toolCallIndex] = {};
537 }538 }
public/scripts/util/stream-fadein.js+69 -0
@@ -0,0 +1,69 @@
1import { 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 */
7export 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 */
17export 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 */
65export 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 {
3 DOMPurify,3 DOMPurify,
4 Readability,4 Readability,
5 isProbablyReaderable,5 isProbablyReaderable,
6 lodash,
6} from '../lib.js';7} from '../lib.js';
78
8import { getContext } from './extensions.js';9import { getContext } from './extensions.js';
9import { characters, getRequestHeaders, this_chid, user_avatar } from '../script.js';10import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js';
10import { isMobile } from './RossAscends-mods.js';11import { isMobile } from './RossAscends-mods.js';
11import { collapseNewlines, power_user } from './power-user.js';12import { collapseNewlines, power_user } from './power-user.js';
12import { debounce_timeout } from './constants.js';13import { debounce_timeout } from './constants.js';
@@ -15,6 +16,10 @@ import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
15import { getTagsList } from './tags.js';16import { getTagsList } from './tags.js';
16import { groups, selected_group } from './group-chats.js';17import { groups, selected_group } from './group-chats.js';
17import { getCurrentLocale, t } from './i18n.js';18import { getCurrentLocale, t } from './i18n.js';
19import { importWorldInfo } from './world-info.js';
20
21export const shiftUpByOne = (e, i, a) => a[i] = e + 1;
22export const shiftDownByOne = (e, i, a) => a[i] = e - 1;
1823
19/**24/**
20 * Pagination status string template.25 * Pagination status string template.
@@ -25,6 +30,8 @@ export const PAGINATION_TEMPLATE = '<%= rangeStart %>-<%= rangeEnd %> .. <%= tot
25export const localizePagination = function(container) {30export const localizePagination = function(container) {
26 container.find('[title="Next page"]').attr('title', t`Next page`);31 container.find('[title="Next page"]').attr('title', t`Next page`);
27 container.find('[title="Previous page"]').attr('title', t`Previous page`);32 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`);
28};35};
2936
30/**37/**
@@ -285,6 +292,15 @@ export function removeFromArray(array, item) {
285}292}
286293
287/**294/**
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 */
299export function normalizeArray(arr) {
300 return [...new Set((arr ?? []).map(s => typeof s === 'string' ? s.trim() : s).filter(Boolean))];
301}
302
303/**
288 * Checks if a string only contains digits.304 * Checks if a string only contains digits.
289 * @param {string} str The string to check.305 * @param {string} str The string to check.
290 * @returns {boolean} True if the string only contains digits, false otherwise.306 * @returns {boolean} True if the string only contains digits, false otherwise.
@@ -616,7 +632,7 @@ export function isElementInViewport(el) {
616/**632/**
617 * Returns a name that is unique among the names that exist.633 * Returns a name that is unique among the names that exist.
618 * @param {string} name The name to check.634 * @param {string} name The name to check.
619 * @param {{ (y: any): boolean; }} exists Function to check if name exists.635 * @param {{ (name: string): boolean; }} exists Function to check if name exists.
620 * @returns {string} A unique name.636 * @returns {string} A unique name.
621 */637 */
622export function getUniqueName(name, exists) {638export function getUniqueName(name, exists) {
@@ -1633,13 +1649,18 @@ export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type
1633 * @param {{ (): boolean; }} condition The condition to wait for.1649 * @param {{ (): boolean; }} condition The condition to wait for.
1634 * @param {number} [timeout=1000] The timeout in milliseconds.1650 * @param {number} [timeout=1000] The timeout in milliseconds.
1635 * @param {number} [interval=100] The interval in milliseconds.1651 * @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.
1636 * @returns {Promise<void>} A promise that resolves when the condition is true.1654 * @returns {Promise<void>} A promise that resolves when the condition is true.
1637 */1655 */
1638export async function waitUntilCondition(condition, timeout = 1000, interval = 100) {1656export async function waitUntilCondition(condition, timeout = 1000, interval = 100, options = {}) {
1657 const { rejectOnTimeout = true } = options;
1658
1639 return new Promise((resolve, reject) => {1659 return new Promise((resolve, reject) => {
1640 const timeoutId = setTimeout(() => {1660 const timeoutId = setTimeout(() => {
1641 clearInterval(intervalId);1661 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'));
1643 }, timeout);1664 }, timeout);
16441665
1645 const intervalId = setInterval(() => {1666 const intervalId = setInterval(() => {
@@ -2560,3 +2581,110 @@ export function textValueMatcher(params, data) {
2560export function versionCompare(srcVersion, minVersion) {2581export function versionCompare(srcVersion, minVersion) {
2561 return (srcVersion || '0.0.0').localeCompare(minVersion, undefined, { numeric: true, sensitivity: 'base' }) > -1;2582 return (srcVersion || '0.0.0').localeCompare(minVersion, undefined, { numeric: true, sensitivity: 'base' }) > -1;
2562}2583}
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 */
2594export 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 */
2642export 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+323 -63
@@ -1,7 +1,7 @@
1import { Fuse } from '../lib.js';1import { Fuse } from '../lib.js';
22
3import { saveSettings, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';3import { 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';
4import { 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 } from './utils.js';4import { 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';
5import { extension_settings, getContext } from './extensions.js';5import { extension_settings, getContext } from './extensions.js';
6import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';6import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
7import { isMobile } from './RossAscends-mods.js';7import { isMobile } from './RossAscends-mods.js';
@@ -22,6 +22,7 @@ import { StructuredCloneMap } from './util/StructuredCloneMap.js';
22import { renderTemplateAsync } from './templates.js';22import { renderTemplateAsync } from './templates.js';
23import { t } from './i18n.js';23import { t } from './i18n.js';
24import { accountStorage } from './util/AccountStorage.js';24import { accountStorage } from './util/AccountStorage.js';
25import { getOrCreatePersonaDescriptor, setPersonaDescription, user_avatar } from './personas.js';
2526
26export const world_info_insertion_strategy = {27export const world_info_insertion_strategy = {
27 evenly: 0,28 evenly: 0,
@@ -156,6 +157,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
156 * @property {Array} worldInfoDepth - Array of depth entries157 * @property {Array} worldInfoDepth - Array of depth entries
157 * @property {Array} anBefore - Array of entries before Author's Note158 * @property {Array} anBefore - Array of entries before Author's Note
158 * @property {Array} anAfter - Array of entries after Author's Note159 * @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
159 */161 */
160162
161/**163/**
@@ -166,6 +168,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
166 * @property {any[]} WIDepthEntries The depth entries.168 * @property {any[]} WIDepthEntries The depth entries.
167 * @property {any[]} ANBeforeEntries The entries before Author's Note.169 * @property {any[]} ANBeforeEntries The entries before Author's Note.
168 * @property {any[]} ANAfterEntries The entries after Author's Note.170 * @property {any[]} ANAfterEntries The entries after Author's Note.
171 * @property {{[key: string]: string[]}} outletEntries - Array of entries to be added to an outlet
169 * @property {Set<any>} allActivatedEntries All entries.172 * @property {Set<any>} allActivatedEntries All entries.
170 */173 */
171174
@@ -817,6 +820,7 @@ export const world_info_position = {
817 atDepth: 4,820 atDepth: 4,
818 EMTop: 5,821 EMTop: 5,
819 EMBottom: 6,822 EMBottom: 6,
823 outlet: 7,
820};824};
821825
822export const wi_anchor_position = {826export const wi_anchor_position = {
@@ -866,6 +870,7 @@ export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanD
866 worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],870 worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],
867 anBefore: activatedWorldInfo.ANBeforeEntries ?? [],871 anBefore: activatedWorldInfo.ANBeforeEntries ?? [],
868 anAfter: activatedWorldInfo.ANAfterEntries ?? [],872 anAfter: activatedWorldInfo.ANAfterEntries ?? [],
873 outletEntries: activatedWorldInfo.outletEntries ?? {},
869 };874 };
870}875}
871876
@@ -1037,39 +1042,69 @@ function registerWorldInfoSlashCommands() {
10371042
1038 /**1043 /**
1039 * Gets the name of the persona-bound lorebook.1044 * Gets the name of the persona-bound lorebook.
1040 * @returns {string} The name of the persona-bound lorebook1045 * @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
1041 */1048 */
1042 function getPersonaBookCallback() {1049 async function getPersonaBookCallback({ name, create }, _unnamedArg) {
1043 return power_user.persona_description_lorebook || '';1050 let 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 '';
1044 }1064 }
10451065
1046 /**1066 /**
1047 * Gets the name of the character-bound lorebook.1067 * Gets the name of the character-bound lorebook.
1048 * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments1068 * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments
1049 * @param {string} name Character name1069 * @param {string} characterIdentifier Character name
1050 * @returns {string} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string1070 * @returns {Promise<string>} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string
1051 */1071 */
1052 function getCharBookCallback({ type }, name) {1072 async function getCharBookCallback({ type, name, create }, characterIdentifier) {
1053 const context = getContext();1073 const context = getContext();
1054 if (context.groupId && !name) throw new Error('This command is not available in groups without providing a character name');1074 if (context.groupId && !characterIdentifier) throw new Error('This command is not available in groups without providing a character name');
1055 type = String(type ?? '').trim().toLowerCase() || 'primary';1075 type = String(type ?? '').trim().toLowerCase() || 'primary';
1056 name = String(name ?? '') || context.characters[context.characterId]?.avatar || null;1076 characterIdentifier = String(characterIdentifier ?? '') || context.characters[context.characterId]?.avatar || null;
1057 const character = findChar({ name });1077 const character = findChar({ name: characterIdentifier });
1058 if (!character) {1078 if (!character) {
1059 toastr.error(t`Character not found.`);1079 toastr.error(t`Character not found.`);
1060 return '';1080 return '';
1061 }1081 }
1062 const books = [];1082 const books = [];
1063 if (type === 'all' || type === 'primary') {1083 if (type === 'all' || type === 'primary' && character.data?.extensions?.world) {
1064 books.push(character.data?.extensions?.world);1084 books.push(character.data.extensions.world);
1065 }1085 }
1066 if (type === 'all' || type === 'additional') {1086 if (type === 'all' || type === 'additional') {
1067 const fileName = getCharaFilename(context.characters.indexOf(character));1087 const fileName = getCharaFilename(context.characters.indexOf(character));
1068 const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);1088 const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
1069 if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) {1089 if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) {
1070 books.push(...extraCharLore.extraBooks);1090 books.push(...extraCharLore.extraBooks.filter(onlyUnique).filter(Boolean));
1071 }1091 }
1072 }1092 }
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
1073 return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean));1108 return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean));
1074 }1109 }
10751110
@@ -1090,10 +1125,23 @@ function registerWorldInfoSlashCommands() {
1090 return chat_metadata[METADATA_KEY];1125 return chat_metadata[METADATA_KEY];
1091 }1126 }
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 = (() => {
1094 // Use the provided name if it's not in use1142 // Use the provided name if it's not in use
1095 if (typeof args.name === 'string') {1143 if (typeof possibleName === 'string') {
1096 const name = String(args.name);1144 const name = String(possibleName);
1097 if (world_names.includes(name)) {1145 if (world_names.includes(name)) {
1098 throw new Error('This World Info file name is already in use');1146 throw new Error('This World Info file name is already in use');
1099 }1147 }
@@ -1101,14 +1149,14 @@ function registerWorldInfoSlashCommands() {
1101 }1149 }
11021150
1103 // Replace non-alphanumeric characters with underscores, cut to 64 characters1151 // 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()})`;
1105 })();1153 })();
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;
1112 }1160 }
11131161
1114 async function findBookEntryCallback(args, value) {1162 async function findBookEntryCallback(args, value) {
@@ -1545,6 +1593,15 @@ function registerWorldInfoSlashCommands() {
1545 isRequired: false,1593 isRequired: false,
1546 acceptsMultiple: false,1594 acceptsMultiple: false,
1547 }),1595 }),
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 }),
1548 ],1605 ],
1549 aliases: ['getchatlore', 'getchatwi'],1606 aliases: ['getchatlore', 'getchatwi'],
1550 }));1607 }));
@@ -1559,6 +1616,25 @@ function registerWorldInfoSlashCommands() {
1559 name: 'getpersonabook',1616 name: 'getpersonabook',
1560 callback: getPersonaBookCallback,1617 callback: getPersonaBookCallback,
1561 returns: 'lorebook name',1618 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 ],
1562 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.',1638 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.',
1563 aliases: ['getpersonalore', 'getpersonawi'],1639 aliases: ['getpersonalore', 'getpersonawi'],
1564 }));1640 }));
@@ -1574,6 +1650,22 @@ function registerWorldInfoSlashCommands() {
1574 enumList: ['primary', 'additional', 'all'],1650 enumList: ['primary', 'additional', 'all'],
1575 defaultValue: 'primary',1651 defaultValue: 'primary',
1576 }),1652 }),
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 }),
1577 ],1669 ],
1578 unnamedArgumentList: [1670 unnamedArgumentList: [
1579 SlashCommandArgument.fromProps({1671 SlashCommandArgument.fromProps({
@@ -3425,6 +3517,19 @@ export async function getWorldEntry(name, data, entry) {
3425 contentInput.val(entry.content).trigger('input', { skipCount: true, noSave: true });3517 contentInput.val(entry.content).trigger('input', { skipCount: true, noSave: true });
3426 editTemplate.find('.editor_maximize').attr('data-for', contentInputId);3518 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
3428 // Scan depth3533 // Scan depth
3429 const scanDepthInput = editTemplate.find('input[name="scanDepth"]');3534 const scanDepthInput = editTemplate.find('input[name="scanDepth"]');
3430 scanDepthInput.data('uid', entry.uid);3535 scanDepthInput.data('uid', entry.uid);
@@ -3597,63 +3702,105 @@ export async function getWorldEntry(name, data, entry) {
3597 return headerTemplate;3702 return headerTemplate;
3598}3703}
35993704
3705
3600/**3706/**
3601 * Get the inclusion groups for the autocomplete.3707 * Builds a jQuery UI autocomplete callback: (control, request, response) => void
3602 * @param {any} data WI data3708 * @param {object} [opt={}] - Optional arguments
3603 * @returns {(input: any, output: any) => any} Callback function for the autocomplete3709 * @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)
3604 */3713 */
3605function getInclusionGroupCallback(data) {3714function buildAutocompleteCallback({ data, collectValues, includeExtras = () => [], postFilter } = {}) {
3606 return function (control, input, output) {3715 return function (control, input, output) {
3607 const uid = $(control).data('uid');3716 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 entries3720 for (const entry of Object.values(data.entries ?? {})) {
3612 if (entry.uid == uid) continue;3721 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);
3615 }3728 }
3616 }3729 }
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()) {
3620 const needle = input.term.toLowerCase();3733 const s = String(v).trim();
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
3624 output(result);3749 output(result);
3625 };3750 };
3626}3751}
36273752
3628function 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 entries3758const 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
3764 */
3765function 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 });
3644}3782}
36453783
3646 const haystack = Array.from(ids);3784function 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);3795function getOutletNameCallback(data) {
3652 };3796 return buildAutocompleteCallback({
3797 data,
3798 collectValues: entry => entry.position === world_info_position.outlet && entry.outletName ? [entry.outletName] : [],
3799 });
3653}3800}
36543801
3655/**3802/**
3656 * Create an autocomplete for the inclusion group.3803 * Create an autocomplete for an input element.
3657 * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to3804 * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to
3658 * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks3805 * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks
3659 * @param {object} [options={}] - Optional arguments3806 * @param {object} [options={}] - Optional arguments
@@ -3770,6 +3917,7 @@ export const newWorldInfoEntryDefinition = {
3770 probability: { default: 100, type: 'number' },3917 probability: { default: 100, type: 'number' },
3771 useProbability: { default: true, type: 'boolean' },3918 useProbability: { default: true, type: 'boolean' },
3772 depth: { default: DEFAULT_DEPTH, type: 'number' },3919 depth: { default: DEFAULT_DEPTH, type: 'number' },
3920 outletName: { default: '', type: 'string' },
3773 group: { default: '', type: 'string' },3921 group: { default: '', type: 'string' },
3774 groupOverride: { default: false, type: 'boolean' },3922 groupOverride: { default: false, type: 'boolean' },
3775 groupWeight: { default: DEFAULT_WEIGHT, type: 'number' },3923 groupWeight: { default: DEFAULT_WEIGHT, type: 'number' },
@@ -3935,6 +4083,16 @@ export async function deleteWorldInfo(worldInfoName) {
3935 }4083 }
3936 }4084 }
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
3938 return true;4096 return true;
3939}4097}
39404098
@@ -4281,7 +4439,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
4281 timedEffects.checkTimedEffects();4439 timedEffects.checkTimedEffects();
42824440
4283 if (sortedEntries.length === 0) {4441 if (sortedEntries.length === 0) {
4284 return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], ANBeforeEntries: [], ANAfterEntries: [], allActivatedEntries: new Set() };4442 return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], ANBeforeEntries: [], ANAfterEntries: [], outletEntries: {}, allActivatedEntries: new Set() };
4285 }4443 }
42864444
4287 /** @type {number[]} Represents the delay levels for entries that are delayed until recursion */4445 /** @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
4682 const ANTopEntries = [];4840 const ANTopEntries = [];
4683 const ANBottomEntries = [];4841 const ANBottomEntries = [];
4684 const WIDepthEntries = [];4842 const WIDepthEntries = [];
4843 /** @type {{[key: string]: string[]}} */
4844 const WIOutletEntries = {};
46854845
4686 // Appends from insertion order 999 to 1. Use unshift for this purpose4846 // Appends from insertion order 999 to 1. Use unshift for this purpose
4687 // TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays4847 // 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
4730 }4890 }
4731 break;4891 break;
4732 }4892 }
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 }
4733 default:4905 default:
4734 break;4906 break;
4735 }4907 }
@@ -4751,7 +4923,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
4751 console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));4923 console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));
4752 console.debug(`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---`);4924 console.debug(`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---`);
47534925
4754 return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, ANBeforeEntries: ANTopEntries, ANAfterEntries: ANBottomEntries, allActivatedEntries: new Set(allActivatedEntries.values()) };4926 return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, ANBeforeEntries: ANTopEntries, ANAfterEntries: ANBottomEntries, outletEntries: WIOutletEntries, allActivatedEntries: new Set(allActivatedEntries.values()) };
4755}4927}
47564928
4757/**4929/**
@@ -4971,6 +5143,7 @@ function convertAgnaiMemoryBook(inputObj) {
4971 displayIndex: index,5143 displayIndex: index,
4972 probability: 100,5144 probability: 100,
4973 useProbability: true,5145 useProbability: true,
5146 outletName: '',
4974 group: '',5147 group: '',
4975 groupOverride: false,5148 groupOverride: false,
4976 groupWeight: DEFAULT_WEIGHT,5149 groupWeight: DEFAULT_WEIGHT,
@@ -5015,6 +5188,7 @@ function convertRisuLorebook(inputObj) {
5015 displayIndex: index,5188 displayIndex: index,
5016 probability: entry.activationPercent ?? 100,5189 probability: entry.activationPercent ?? 100,
5017 useProbability: entry.activationPercent ?? true,5190 useProbability: entry.activationPercent ?? true,
5191 outletName: '',
5018 group: '',5192 group: '',
5019 groupOverride: false,5193 groupOverride: false,
5020 groupWeight: DEFAULT_WEIGHT,5194 groupWeight: DEFAULT_WEIGHT,
@@ -5064,6 +5238,7 @@ function convertNovelLorebook(inputObj) {
5064 displayIndex: index,5238 displayIndex: index,
5065 probability: 100,5239 probability: 100,
5066 useProbability: true,5240 useProbability: true,
5241 outletName: '',
5067 group: '',5242 group: '',
5068 groupOverride: false,5243 groupOverride: false,
5069 groupWeight: DEFAULT_WEIGHT,5244 groupWeight: DEFAULT_WEIGHT,
@@ -5114,6 +5289,7 @@ export function convertCharacterBook(characterBook) {
5114 useProbability: entry.extensions?.useProbability ?? true,5289 useProbability: entry.extensions?.useProbability ?? true,
5115 depth: entry.extensions?.depth ?? DEFAULT_DEPTH,5290 depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
5116 selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,5291 selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
5292 outletName: entry.extensions?.outlet_name ?? '',
5117 group: entry.extensions?.group ?? '',5293 group: entry.extensions?.group ?? '',
5118 groupOverride: entry.extensions?.group_override ?? false,5294 groupOverride: entry.extensions?.group_override ?? false,
5119 groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,5295 groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,
@@ -5558,6 +5734,90 @@ export async function moveWorldInfoEntry(sourceName, targetName, uid, { deleteOr
5558 }5734 }
5559}5735}
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 */
5743export 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 */
5780export 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 */
5791export function charSetAuxWorlds(fileName, books) {
5792 updateAuxBooks(fileName, _ => Array.isArray(books) ? books : []);
5793}
5794
5795function 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
5561export function initWorldInfo() {5821export function initWorldInfo() {
5562 $('#world_info').on('mousedown change', async function (e) {5822 $('#world_info').on('mousedown change', async function (e) {
5563 // If there's no world names, don't do anything5823 // If there's no world names, don't do anything
public/style.css+27 -3
@@ -133,6 +133,7 @@
133* {133* {
134 box-sizing: border-box;134 box-sizing: border-box;
135 -webkit-font-smoothing: antialiased;135 -webkit-font-smoothing: antialiased;
136 -webkit-tap-highlight-color: transparent;
136 -moz-osx-font-smoothing: grayscale;137 -moz-osx-font-smoothing: grayscale;
137 text-shadow: 0px 0px calc(var(--shadowWidth) * 1px) var(--SmartThemeShadowColor);138 text-shadow: 0px 0px calc(var(--shadowWidth) * 1px) var(--SmartThemeShadowColor);
138}139}
@@ -599,6 +600,14 @@ input[type='checkbox']:focus-visible {
599 max-height: var(--doc-height);600 max-height: var(--doc-height);
600}601}
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
602.mes .mes_timer,611.mes .mes_timer,
603.mes .mesIDDisplay,612.mes .mesIDDisplay,
604.mes .tokenCounterDisplay {613.mes .tokenCounterDisplay {
@@ -2923,6 +2932,10 @@ select option:not(:checked) {
2923 color: var(--white70a);2932 color: var(--white70a);
2924}2933}
29252934
2935select option[disabled]:not(:checked) {
2936 color: var(--white30a);
2937}
2938
2926/*#######################################################################*/2939/*#######################################################################*/
29272940
2928#rm_api_block {2941#rm_api_block {
@@ -2940,6 +2953,7 @@ select option:not(:checked) {
2940 text-align: center;2953 text-align: center;
2941}2954}
29422955
2956.menu_button[disabled],
2943.menu_button.disabled {2957.menu_button.disabled {
2944 filter: brightness(75%) grayscale(1);2958 filter: brightness(75%) grayscale(1);
2945 opacity: 0.5;2959 opacity: 0.5;
@@ -3001,6 +3015,11 @@ select option:not(:checked) {
3001 display: block;3015 display: block;
3002}3016}
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
3004.menu_button.api_button:hover {3023.menu_button.api_button:hover {
3005 background-color: var(--active);3024 background-color: var(--active);
3006}3025}
@@ -3286,6 +3305,10 @@ input[type=search]:focus::-webkit-search-cancel-button {
3286 position: relative;3305 position: relative;
3287}3306}
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
3289.group_member .queue_position:not(:empty)::before {3312.group_member .queue_position:not(:empty)::before {
3290 content: "#";3313 content: "#";
3291}3314}
@@ -3675,8 +3698,8 @@ grammarly-extension {
3675 min-width: calc(1.25em + 12px);3698 min-width: calc(1.25em + 12px);
3676}3699}
36773700
3678.menu_button:not(.disabled):hover,3701.menu_button:not(.disabled):not([disabled]):hover,
3679.menu_button:not(.disabled).active {3702.menu_button:not(.disabled):not([disabled]).active {
3680 background-color: var(--white30a);3703 background-color: var(--white30a);
3681}3704}
36823705
@@ -5740,7 +5763,8 @@ body:not(.movingUI) .drawer-content.maximized {
5740}5763}
57415764
5742.paginationjs-pages ul li a {5765.paginationjs-pages ul li a {
5743 padding: 0.05em 0.5em;5766 padding: 0.25em;
5767 font-family: 'Font Awesome 6 Free';
5744 text-decoration: none;5768 text-decoration: none;
5745 color: var(--SmartThemeBodyColor);5769 color: var(--SmartThemeBodyColor);
5746 border: 1px solid var(--SmartThemeBorderColor);5770 border: 1px solid var(--SmartThemeBorderColor);
src/config-init.js+10 -0
@@ -124,6 +124,16 @@ const keyMigrationMap = [
124 migrate: () => void 0,124 migrate: () => void 0,
125 remove: true,125 remove: true,
126 },126 },
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 },
127];137];
128138
129/**139/**
src/endpoints/backends/chat-completions.js+73 -26
@@ -154,9 +154,9 @@ async function sendClaudeRequest(request, response) {
154 const useTools = Array.isArray(request.body.tools) && request.body.tools.length > 0;154 const useTools = Array.isArray(request.body.tools) && request.body.tools.length > 0;
155 const useSystemPrompt = Boolean(request.body.claude_use_sysprompt);155 const useSystemPrompt = Boolean(request.body.claude_use_sysprompt);
156 const convertedPrompt = convertClaudeMessages(request.body.messages, request.body.assistant_prefill, useSystemPrompt, useTools, getPromptNames(request));156 const convertedPrompt = convertClaudeMessages(request.body.messages, request.body.assistant_prefill, useSystemPrompt, useTools, getPromptNames(request));
157 const useThinking = /^claude-(3-7|opus-4|sonnet-4)/.test(request.body.model);157 const useThinking = /^claude-(3-7|opus-4|sonnet-4|haiku-4-5)/.test(request.body.model);
158 const useWebSearch = /^claude-(3-5|3-7|opus-4|sonnet-4)/.test(request.body.model) && Boolean(request.body.enable_web_search);158 const useWebSearch = /^claude-(3-5|3-7|opus-4|sonnet-4|haiku-4-5)/.test(request.body.model) && Boolean(request.body.enable_web_search);
159 const isOpus41 = /^claude-opus-4-1/.test(request.body.model);159 const isLimitedSampling = /^claude-(opus-4-1|sonnet-4-5|haiku-4-5)/.test(request.body.model);
160 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';160 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';
161 let fixThinkingPrefill = false;161 let fixThinkingPrefill = false;
162 // Add custom stop sequences162 // Add custom stop sequences
@@ -226,7 +226,7 @@ async function sendClaudeRequest(request, response) {
226 betaHeaders.push('extended-cache-ttl-2025-04-11');226 betaHeaders.push('extended-cache-ttl-2025-04-11');
227 }227 }
228228
229 if (isOpus41) {229 if (isLimitedSampling) {
230 if (requestBody.top_p < 1) {230 if (requestBody.top_p < 1) {
231 delete requestBody.temperature;231 delete requestBody.temperature;
232 } else {232 } else {
@@ -377,24 +377,16 @@ async function sendMakerSuiteRequest(request, response) {
377 'gemini-2.0-flash-exp-image-generation',377 'gemini-2.0-flash-exp-image-generation',
378 'gemini-2.0-flash-preview-image-generation',378 'gemini-2.0-flash-preview-image-generation',
379 'gemini-2.5-flash-image-preview',379 'gemini-2.5-flash-image-preview',
380 'gemini-2.5-flash-image',
380 ];381 ];
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
392 const noSearchModels = [385 const noSearchModels = [
393 'gemini-2.0-flash-lite',386 'gemini-2.0-flash-lite',
394 'gemini-2.0-flash-lite-001',387 'gemini-2.0-flash-lite-001',
395 'gemini-2.0-flash-lite-preview-02-05',388 'gemini-2.0-flash-lite-preview-02-05',
396 'gemini-1.5-flash-8b-exp-0924',389 'gemini-robotics-er-1.5-preview',
397 'gemini-1.5-flash-8b-exp-0827',
398 ];390 ];
399 // #endregion391 // #endregion
400392
@@ -413,15 +405,8 @@ async function sendMakerSuiteRequest(request, response) {
413 const prompt = convertGooglePrompt(request.body.messages, model, useSystemPrompt, getPromptNames(request));405 const prompt = convertGooglePrompt(request.body.messages, model, useSystemPrompt, getPromptNames(request));
414 let safetySettings = GEMINI_SAFETY;406 let safetySettings = GEMINI_SAFETY;
415407
416 if (blockNoneModels.includes(model)) {
417 safetySettings = GEMINI_SAFETY.map(setting => ({ ...setting, threshold: 'BLOCK_NONE' }));
418 }
419
420 if (enableWebSearch && !enableImageModality && !isGemma && !isLearnLM && !noSearchModels.includes(model)) {408 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);
425 }410 }
426411
427 if (Array.isArray(request.body.tools) && request.body.tools.length > 0 && !enableImageModality && !isGemma) {412 if (Array.isArray(request.body.tools) && request.body.tools.length > 0 && !enableImageModality && !isGemma) {
@@ -1012,7 +997,7 @@ async function sendXaiRequest(request, response) {
1012 bodyParams['stop'] = request.body.stop;997 bodyParams['stop'] = request.body.stop;
1013 }998 }
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) {
1016 bodyParams['reasoning_effort'] = request.body.reasoning_effort === 'high' ? 'high' : 'low';1001 bodyParams['reasoning_effort'] = request.body.reasoning_effort === 'high' ? 'high' : 'low';
1017 }1002 }
10181003
@@ -1255,6 +1240,7 @@ async function sendElectronHubRequest(request, response) {
1255 'frequency_penalty': request.body.frequency_penalty,1240 'frequency_penalty': request.body.frequency_penalty,
1256 'top_p': request.body.top_p,1241 'top_p': request.body.top_p,
1257 'top_k': request.body.top_k,1242 'top_k': request.body.top_k,
1243 'logit_bias': request.body.logit_bias,
1258 'seed': request.body.seed,1244 'seed': request.body.seed,
1259 ...bodyParams,1245 ...bodyParams,
1260 };1246 };
@@ -1848,9 +1834,9 @@ router.post('/generate', function (request, response) {
1848 }1834 }
18491835
1850 const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number');1836 const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number');
1851 const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4)/.test(request.body.model);1837 const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4|haiku-4)/.test(request.body.model);
1852 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';1838 const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m';
1853 if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && isClaude3or4) {1839 if (Array.isArray(request.body.messages) && Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && isClaude3or4) {
1854 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth, cacheTTL);1840 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth, cacheTTL);
1855 }1841 }
18561842
@@ -2209,4 +2195,65 @@ multimodalModels.post('/electronhub', async (_req, res) => {
2209 }2195 }
2210});2196});
22112197
2198multimodalModels.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
2226multimodalModels.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
2212router.use('/multimodal-models', multimodalModels);2259router.use('/multimodal-models', multimodalModels);
src/endpoints/characters.js+2 -0
@@ -108,6 +108,7 @@ class DiskCache {
108 dir: this.cachePath,108 dir: this.cachePath,
109 ttl: false,109 ttl: false,
110 forgiveParseErrors: true,110 forgiveParseErrors: true,
111 expiredInterval: 0,
111 // @ts-ignore112 // @ts-ignore
112 maxFileDescriptors: 100,113 maxFileDescriptors: 100,
113 });114 });
@@ -688,6 +689,7 @@ function convertWorldInfoToCharacterBook(name, entries) {
688 useProbability: entry.useProbability ?? false,689 useProbability: entry.useProbability ?? false,
689 depth: entry.depth ?? 4,690 depth: entry.depth ?? 4,
690 selectiveLogic: entry.selectiveLogic ?? 0,691 selectiveLogic: entry.selectiveLogic ?? 0,
692 outlet_name: entry.outletName ?? '',
691 group: entry.group ?? '',693 group: entry.group ?? '',
692 group_override: entry.groupOverride ?? false,694 group_override: entry.groupOverride ?? false,
693 group_weight: entry.groupWeight ?? null,695 group_weight: entry.groupWeight ?? null,
src/endpoints/chats.js+5 -2
@@ -628,6 +628,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
628 const avatarUrl = (request.body.avatar_url).replace('.png', '');628 const avatarUrl = (request.body.avatar_url).replace('.png', '');
629 const characterName = request.body.character_name;629 const characterName = request.body.character_name;
630 const userName = request.body.user_name || 'User';630 const userName = request.body.user_name || 'User';
631 const fileNames = [];
631632
632 if (!request.file) {633 if (!request.file) {
633 return response.sendStatus(400);634 return response.sendStatus(400);
@@ -662,6 +663,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
662 const handleChat = (chat) => {663 const handleChat = (chat) => {
663 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;664 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
664 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);665 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
666 fileNames.push(fileName);
665 writeFileAtomicSync(filePath, chat, 'utf8');667 writeFileAtomicSync(filePath, chat, 'utf8');
666 };668 };
667669
@@ -673,7 +675,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
673 handleChat(chat);675 handleChat(chat);
674 }676 }
675677
676 return response.send({ res: true });678 return response.send({ res: true, fileNames });
677 }679 }
678680
679 if (format === 'jsonl') {681 if (format === 'jsonl') {
@@ -700,13 +702,14 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
700702
701 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;703 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;
702 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);704 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
705 fileNames.push(fileName);
703 if (flattenedChat !== data) {706 if (flattenedChat !== data) {
704 writeFileAtomicSync(filePath, flattenedChat, 'utf8');707 writeFileAtomicSync(filePath, flattenedChat, 'utf8');
705 } else {708 } else {
706 fs.copyFileSync(pathToUpload, filePath);709 fs.copyFileSync(pathToUpload, filePath);
707 }710 }
708 fs.unlinkSync(pathToUpload);711 fs.unlinkSync(pathToUpload);
709 response.send({ res: true });712 response.send({ res: true, fileNames });
710 }713 }
711 } catch (error) {714 } catch (error) {
712 console.error(error);715 console.error(error);
src/endpoints/openai.js+98 -0
@@ -325,6 +325,104 @@ router.post('/generate-voice', async (request, response) => {
325 }325 }
326});326});
327327
328// ElectronHub TTS proxy
329router.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
395router.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
328router.post('/generate-image', async (request, response) => {426router.post('/generate-image', async (request, response) => {
329 try {427 try {
330 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);428 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
src/endpoints/secrets.js+3 -2
@@ -561,12 +561,13 @@ router.post('/find', (request, response) => {
561 }561 }
562562
563 const manager = new SecretManager(request.user.directories);563 const manager = new SecretManager(request.user.directories);
564 const secretValue = manager.readSecret(key, id);564 const state = manager.getSecretState();
565565
566 if (!secretValue) {566 if (!state[key]) {
567 return response.sendStatus(404);567 return response.sendStatus(404);
568 }568 }
569569
570 const secretValue = manager.readSecret(key, id);
570 return response.send({ value: secretValue });571 return response.send({ value: secretValue });
571 } catch (error) {572 } catch (error) {
572 console.error('Error finding secret:', error);573 console.error('Error finding secret:', error);
src/endpoints/vectors.js+1 -1
@@ -187,7 +187,7 @@ function getSourceSettings(source, request) {
187 case 'palm':187 case 'palm':
188 case 'vertexai':188 case 'vertexai':
189 return {189 return {
190 model: String(request.body.model || 'text-embedding-004'),190 model: String(request.body.model || 'text-embedding-005'),
191 request: request, // Pass the request object to get API key and URL191 request: request, // Pass the request object to get API key and URL
192 };192 };
193 case 'mistral':193 case 'mistral':
src/middleware/webpack-serve.js+2 -1
@@ -14,8 +14,9 @@ export default function getWebpackServeMiddleware() {
14 const publicLibConfig = getPublicLibConfig();14 const publicLibConfig = getPublicLibConfig();
15 const outputPath = publicLibConfig.output?.path;15 const outputPath = publicLibConfig.output?.path;
16 const outputFile = publicLibConfig.output?.filename;16 const outputFile = publicLibConfig.output?.filename;
17 const parsedPath = path.parse(req.path);
1718
18 if (req.method === 'GET' && path.parse(req.path).base === outputFile) {19 if (req.method === 'GET' && parsedPath.dir === '/' && parsedPath.base === outputFile) {
19 return res.sendFile(outputFile, { root: outputPath });20 return res.sendFile(outputFile, { root: outputPath });
20 }21 }
2122
src/users.js+30 -3
@@ -24,7 +24,8 @@ import { serverDirectory } from './server-directory.js';
24export const KEY_PREFIX = 'user:';24export const KEY_PREFIX = 'user:';
25const AVATAR_PREFIX = 'avatar:';25const AVATAR_PREFIX = 'avatar:';
26const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');26const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
27const AUTHELIA_AUTH = getConfigValue('autheliaAuth', false, 'boolean');27const AUTHELIA_AUTH = getConfigValue('sso.autheliaAuth', false, 'boolean');
28const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean');
28const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');29const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');
29const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');30const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
3031
@@ -511,6 +512,7 @@ export async function initUserStorage(dataRoot) {
511 await storage.init({512 await storage.init({
512 dir: path.join(dataRoot, '_storage'),513 dir: path.join(dataRoot, '_storage'),
513 ttl: false, // Never expire514 ttl: false, // Never expire
515 expiredInterval: 0,
514 });516 });
515517
516 const keys = await getAllUserHandles();518 const keys = await getAllUserHandles();
@@ -715,6 +717,10 @@ export async function tryAutoLogin(request, basicAuthMode) {
715 return true;717 return true;
716 }718 }
717719
720 if (AUTHENTIK_AUTH && await authentikUserLogin(request)) {
721 return true;
722 }
723
718 if (basicAuthMode && PER_USER_BASIC_AUTH && await basicUserLogin(request)) {724 if (basicAuthMode && PER_USER_BASIC_AUTH && await basicUserLogin(request)) {
719 return true;725 return true;
720 }726 }
@@ -745,20 +751,41 @@ async function singleUserLogin(request) {
745}751}
746752
747/**753/**
748 * Tries auto-login with authlia trusted headers.754 * Attempts auto-login using an Authelia header.
749 * https://www.authelia.com/integration/trusted-header-sso/introduction/755 * https://www.authelia.com/integration/trusted-header-sso/introduction/
750 * @param {import('express').Request} request Request object756 * @param {import('express').Request} request Request object
751 * @returns {Promise<boolean>} Whether auto-login was performed757 * @returns {Promise<boolean>} Whether auto-login was performed
752 */758 */
753async function autheliaUserLogin(request) {759async 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 */
769async 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 */
779async function headerUserLogin(request, header = 'Remote-User') {
754 if (!request.session) {780 if (!request.session) {
755 return false;781 return false;
756 }782 }
757783
758 const remoteUser = request.get('Remote-User');784 const remoteUser = request.get(header);
759 if (!remoteUser) {785 if (!remoteUser) {
760 return false;786 return false;
761 }787 }
788 console.debug(`Attempting auto-login for user from header ${header}: ${remoteUser}`);
762789
763 const userHandles = await getAllUserHandles();790 const userHandles = await getAllUserHandles();
764 for (const userHandle of userHandles) {791 for (const userHandle of userHandles) {