Merge branch 'staging' into integrity

5ab284f1f5e262cee16a4edc6a905a00750adc4e

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

19 files changed, +748 -132Showing whitespace changes
.github/workflows/issues-auto-manager.yml+13 -9
@@ -7,6 +7,10 @@ on:
77 issue_comment:
88 types: [created]
99
10+permissions:
11+ contents: read
12+ issues: write
13+
1014jobs:
1115 label-on-content:
1216 name: 🏷️ Label Issues by Content
@@ -16,7 +20,7 @@ jobs:
1620 - name: Checkout Repository
1721 # Checkout
1822 # https://github.com/marketplace/actions/checkout
1923 uses: actions/checkout@v4.2.2
2024
2125 - name: Auto-Label Issues (Based on Issue Content)
2226 # only auto label based on issue content once, on open (to prevent re-labeling removed labels)
@@ -24,7 +28,7 @@ jobs:
2428
2529 # Issue Labeler
2630 # https://github.com/marketplace/actions/regex-issue-labeler
2731 uses: github/issue-labeler@v3.4
2832 with:
2933 configuration-path: .github/issues-auto-labels.yml
3034 enable-versioned-regex: 0
@@ -39,7 +43,7 @@ jobs:
3943 if: contains(fromJSON('["πŸ‘©β€πŸ’» Good First Issue", "πŸ™ Help Wanted", "πŸͺ² Confirmed", "⚠️ High Priority", "❕ Medium Priority", "πŸ’€ Low Priority"]'), github.event.label.name)
4044 # πŸ€– Issues Helper
4145 # https://github.com/marketplace/actions/issues-helper
4246 uses: actions-cool/issues-helper@v3.6.0
4347 with:
4448 actions: 'add-labels'
4549 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -49,7 +53,7 @@ jobs:
4953 if: contains(fromJSON('["βœ… Done", "βœ… Done (staging)", "⚰️ Stale", "❌ wontfix"]'), github.event.label.name)
5054 # πŸ€– Issues Helper
5155 # https://github.com/marketplace/actions/issues-helper
5256 uses: actions-cool/issues-helper@v3.6.0
5357 with:
5458 actions: 'remove-labels'
5559 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -59,7 +63,7 @@ jobs:
5963 if: contains(fromJSON('["❌ wontfix","πŸ‘ Approved","πŸ‘©β€πŸ’» Good First Issue"]'), github.event.label.name)
6064 # πŸ€– Issues Helper
6165 # https://github.com/marketplace/actions/issues-helper
6266 uses: actions-cool/issues-helper@v3.6.0
6367 with:
6468 actions: 'remove-labels'
6569 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -69,7 +73,7 @@ jobs:
6973 if: github.event.label.name == 'πŸͺ² Confirmed'
7074 # πŸ€– Issues Helper
7175 # https://github.com/marketplace/actions/issues-helper
7276 uses: actions-cool/issues-helper@v3.6.0
7377 with:
7478 actions: 'remove-labels'
7579 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -85,7 +89,7 @@ jobs:
8589 - name: Remove Stale Label
8690 # πŸ€– Issues Helper
8791 # https://github.com/marketplace/actions/issues-helper
8892 uses: actions-cool/issues-helper@v3.6.0
8993 with:
9094 actions: 'remove-labels'
9195 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -101,12 +105,12 @@ jobs:
101105 - name: Checkout Repository
102106 # Checkout
103107 # https://github.com/marketplace/actions/checkout
104108 uses: actions/checkout@v4.2.2
105109
106110 - name: Post Issue Comments Based on Labels
107111 # Label Commenter
108112 # https://github.com/marketplace/actions/label-commenter
109113 uses: peaceiris/actions-label-commenter@v1.10.0
110114 with:
111115 config_file: .github/issues-auto-comments.yml
112116 github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
.github/workflows/issues-updates-on-merge.yml+6 -2
@@ -6,6 +6,10 @@ on:
66 - staging
77 - release
88
9+permissions:
10+ contents: read
11+ issues: write
12+
913jobs:
1014 # This runs commits to staging/release, reading the commit messages. Check `pr-auto-manager.yml`:`update-linked-issues` for PR-linked updates.
1115 update-linked-issues:
@@ -16,12 +20,12 @@ jobs:
1620 - name: Checkout Repository
1721 # Checkout
1822 # https://github.com/marketplace/actions/checkout
1923 uses: actions/checkout@v4.2.2
2024
2125 - name: Extract Linked Issues from Commit Message
2226 id: extract_issues
2327 run: |
2428 ISSUES=$(git log -1${{ github.event.before }}..${{ github.event.after }} --pretty=%B | grep -oiE '(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved) #([0-9]+)' | awk '{print $2}' | tr -d '#' | jq -R -s -c 'split("\n")[:-1]')
2529 echo "issues=$ISSUES" >> $GITHUB_ENV
2630
2731 - name: Label Linked Issues
.github/workflows/job-close-stale.yml+8 -3
@@ -6,6 +6,11 @@ on:
66 schedule:
77 - cron: '0 0 * * *' # Runs every day at midnight UTC
88
9+permissions:
10+ contents: read
11+ issues: write
12+ pull-requests: write
13+
914jobs:
1015 mark-inactivity:
1116 name: ⏳ Mark Issues/PRs without Activity
@@ -15,7 +20,7 @@ jobs:
1520 - name: Mark Issues/PRs without Activity
1621 # Close Stale Issues and PRs
1722 # https://github.com/marketplace/actions/close-stale-issues
1823 uses: actions/stale@v9.1.0
1924 with:
2025 repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
2126 days-before-stale: 183
@@ -49,7 +54,7 @@ jobs:
4954 - name: Mark Issues/PRs Awaiting User Response
5055 # Close Stale Issues and PRs
5156 # https://github.com/marketplace/actions/close-stale-issues
5257 uses: actions/stale@v9.1.0
5358 with:
5459 repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
5560 days-before-stale: 7
@@ -76,7 +81,7 @@ jobs:
7681 - name: Mark Issues with Alternative Exists
7782 # Close Stale Issues and PRs
7883 # https://github.com/marketplace/actions/close-stale-issues
7984 uses: actions/stale@v9.1.0
8085 with:
8186 repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
8287 days-before-stale: 7
.github/workflows/on-close-handler.yml+6 -1
@@ -6,6 +6,11 @@ on:
66 pull_request_target:
77 types: [closed]
88
9+permissions:
10+ contents: read
11+ issues: write
12+ pull-requests: write
13+
914jobs:
1015 remove-labels:
1116 name: πŸ—‘οΈ Remove Pending Labels on Close
@@ -15,7 +20,7 @@ jobs:
1520 - name: Remove Pending Labels on Close
1621 # πŸ€– Issues Helper
1722 # https://github.com/marketplace/actions/issues-helper
1823 uses: actions-cool/issues-helper@v3.6.0
1924 with:
2025 actions: remove-labels
2126 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
.github/workflows/on-open-handler.yml+6 -1
@@ -6,6 +6,11 @@ on:
66 pull_request_target:
77 types: [opened]
88
9+permissions:
10+ contents: read
11+ issues: write
12+ pull-requests: write
13+
914jobs:
1015 label-maintainer:
1116 name: 🏷️ Label if Author is a Repo Maintainer
@@ -16,7 +21,7 @@ jobs:
1621 - name: Label if Author is a Repo Maintainer
1722 # πŸ€– Issues Helper
1823 # https://github.com/marketplace/actions/issues-helper
1924 uses: actions-cool/issues-helper@v3.6.0
2025 with:
2126 actions: 'add-labels'
2227 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
.github/workflows/pr-auto-manager.yml+21 -11
@@ -6,6 +6,10 @@ on:
66 pull_request_review_comment:
77 types: [created]
88
9+permissions:
10+ contents: read
11+ pull-requests: write
12+
913jobs:
1014 label-by-size:
1115 name: 🏷️ Label PR by Size
@@ -15,7 +19,7 @@ jobs:
1519 - name: Label PR Size
1620 # Pull Request Size Labeler
1721 # https://github.com/marketplace/actions/pull-request-size-labeler
1822 uses: codelytv/pr-size-labeler@v1.10.2
1923 with:
2024 GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
2125 xs_label: '🟩 ⬀○○○○'
@@ -43,12 +47,12 @@ jobs:
4347 - name: Checkout Repository
4448 # Checkout
4549 # https://github.com/marketplace/actions/checkout
4650 uses: actions/checkout@v4.2.2
4751
4852 - name: Apply Labels Based on Branch Name and Target Branch
4953 # Pull Request Labeler
5054 # https://github.com/marketplace/actions/labeler
5155 uses: actions/labeler@v5.0.0
5256 with:
5357 configuration-path: .github/pr-auto-labels-by-branch.yml
5458 repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -61,12 +65,12 @@ jobs:
6165 - name: Checkout Repository
6266 # Checkout
6367 # https://github.com/marketplace/actions/checkout
6468 uses: actions/checkout@v4.2.2
6569
6670 - name: Apply Labels Based on Changed Files
6771 # Pull Request Labeler
6872 # https://github.com/marketplace/actions/labeler
6973 uses: actions/labeler@v5.0.0
7074 with:
7175 configuration-path: .github/pr-auto-labels-by-files.yml
7276 repo-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -75,13 +79,13 @@ jobs:
7579 name: πŸ—‘οΈ Remove Stale Label on Comment
7680 runs-on: ubuntu-latest
7781 # Only runs when this is not done by the github actions bot
7882 if: github.event_name == 'pull_request_review_comment' && github.actor != 'github-actions[bot]'
7983
8084 steps:
8185 - name: Remove Stale Label
8286 # πŸ€– Issues Helper
8387 # https://github.com/marketplace/actions/issues-helper
8488 uses: actions-cool/issues-helper@v3.6.0
8589 with:
8690 actions: 'remove-labels'
8791 token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -95,12 +99,18 @@ jobs:
9599 # Run, even if the previous jobs were skipped/failed
96100 if: always()
97101
102+ # Override permissions, as this needs to write a check
103+ permissions:
104+ checks: write
105+ contents: read
106+ pull-requests: read
107+
98108 steps:
99109 - name: Check Merge Blocking
100110 # GitHub Script
101111 # https://github.com/marketplace/actions/github-scriptLabelsscript
102112 id: label-check
103113 uses: actions/github-script@v7.0.1
104114 with:
105115 script: |
106116 const prLabels = context.payload.pull_request.labels.map(label => label.name);
@@ -143,12 +153,12 @@ jobs:
143153 - name: Checkout Repository
144154 # Checkout
145155 # https://github.com/marketplace/actions/checkout
146156 uses: actions/checkout@v4.2.2
147157
148158 - name: Post PR Comments Based on Labels
149159 # Label Commenter for PRs
150160 # https://github.com/marketplace/actions/label-commenter
151161 uses: peaceiris/actions-label-commenter@v1.10.0
152162 with:
153163 config_file: .github/pr-auto-comments.yml
154164 github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
.github/workflows/pr-check-merge-conflicts.yaml+5 -1
@@ -7,6 +7,10 @@ on:
77 pull_request_target:
88 types: [synchronize]
99
10+permissions:
11+ contents: read
12+ pull-requests: write
13+
1014jobs:
1115 check-merge-conflicts:
1216 name: βš”οΈ Check Merge Conflicts
@@ -16,7 +20,7 @@ jobs:
1620 - name: Check Merge Conflicts
1721 # Label Conflicting Pull Requests
1822 # https://github.com/marketplace/actions/label-conflicting-pull-requests
1923 uses: eps1lon/actions-label-merge-conflict@v3.0.3
2024 with:
2125 dirtyLabel: '🚫 Merge Conflicts'
2226 repoToken: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
public/global.d.ts+4 -0
@@ -1,7 +1,11 @@
11import libs from './lib';
22import getContext from './scripts/st-context';
3+import { power_user } from './scripts/power-user';
34
45declare global {
6+ // Custom types
7+ declare type InstructSettings = typeof power_user.instruct;
8+
59 // Global namespace modules
610 interface Window {
711 ai: any;
public/script.js+60 -25
@@ -515,6 +515,9 @@ export const event_types = {
515515 ONLINE_STATUS_CHANGED: 'online_status_changed',
516516 IMAGE_SWIPED: 'image_swiped',
517517 CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
518+ CONNECTION_PROFILE_CREATED: 'connection_profile_created',
519+ CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
520+ CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
518521 TOOL_CALLS_PERFORMED: 'tool_calls_performed',
519522 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
520523};
@@ -1371,8 +1374,11 @@ export function resultCheckStatus() {
13711374 * If the character ID doesn't exist, if the chat is being saved, or if a group is being generated, this function does nothing.
13721375 * If the character is different from the currently selected one, it will clear the chat and reset any selected character or group.
13731376 * @param {number} id The ID of the character to switch to.
1377+ * @param {object} [options] Options for the switch.
1378+ * @param {boolean} [options.switchMenu=true] Whether to switch the right menu to the character edit menu if the character is already selected.
1379+ * @returns {Promise<void>} A promise that resolves when the character is switched.
13741380 */
13751381export async function selectCharacterById(id, { switchMenu = true } = {}) {
13761382 if (characters[id] === undefined) {
13771383 return;
13781384 }
@@ -1401,9 +1407,9 @@ export async function selectCharacterById(id) {
14011407 }
14021408 } else {
14031409 //if clicked on character that was already selected
14041410 switchMenu && (selected_button = 'character_edit');
14051411 await unshallowCharacter(this_chid);
14061412 select_selected_character(this_chid, { switchMenu });
14071413 }
14081414}
14091415
@@ -1788,6 +1794,7 @@ export async function getCharacters() {
17881794 body: JSON.stringify({}),
17891795 });
17901796 if (response.ok === true) {
1797+ const previousAvatar = this_chid !== undefined ? characters[this_chid]?.avatar : null;
17911798 characters.splice(0, characters.length);
17921799 const getData = await response.json();
17931800 for (let i = 0; i < getData.length; i++) {
@@ -1801,8 +1808,16 @@ export async function getCharacters() {
18011808
18021809 characters[i]['chat'] = String(characters[i]['chat']);
18031810 }
1804- if (this_chid !== undefined) {
1811+
1805- $('#avatar_url_pole').val(characters[this_chid].avatar);
1812+ if (previousAvatar) {
1813+ const newCharacterId = characters.findIndex(x => x.avatar === previousAvatar);
1814+ if (newCharacterId >= 0) {
1815+ setCharacterId(newCharacterId);
1816+ await selectCharacterById(newCharacterId, { switchMenu: false });
1817+ } else {
1818+ await Popup.show.text(t`ERROR: The active character is no longer available.`, t`The page will be refreshed to prevent data loss. Press "OK" to continue.`);
1819+ return location.reload();
1820+ }
18061821 }
18071822
18081823 await getGroups();
@@ -6528,6 +6543,8 @@ export async function renameCharacter(name = null, { silent = false, renameChats
65286543
65296544 await eventSource.emit(event_types.CHARACTER_RENAMED, oldAvatar, newAvatar);
65306545
6546+ // Unload current character
6547+ setCharacterId(undefined);
65316548 // Reload characters list
65326549 await getCharacters();
65336550
@@ -6536,7 +6553,6 @@ export async function renameCharacter(name = null, { silent = false, renameChats
65366553
65376554 if (newChId !== -1) {
65386555 // Select the character after the renaming
6539- setCharacterId(undefined);
65406556 await selectCharacterById(newChId);
65416557
65426558 // Async delay to update UI
@@ -6871,14 +6887,14 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
68716887 */
68726888export async function unshallowCharacter(characterId) {
68736889 if (characterId === undefined) {
68746890 console.warndebug('Undefined character cannot be unshallowed');
68756891 return;
68766892 }
68776893
68786894 /** @type {import('./scripts/char-data.js').v1CharData} */
68796895 const character = characters[characterId];
68806896 if (!character) {
68816897 console.warndebug('Character not found:', characterId);
68826898 return;
68836899 }
68846900
@@ -6889,7 +6905,7 @@ export async function unshallowCharacter(characterId) {
68896905
68906906 const avatar = character.avatar;
68916907 if (!avatar) {
68926908 console.warndebug('Character has no avatar field:', characterId);
68936909 return;
68946910 }
68956911
@@ -7895,14 +7911,19 @@ export function select_rm_info(type, charId, previousCharId = null) {
78957911 }
78967912}
78977913
7898-export function select_selected_character(chid) {
7914+/**
7915+ * Selects the right menu for displaying the character editor.
7916+ * @param {number|string} chid Character array index
7917+ * @param {object} [param1] Options for the switch
7918+ * @param {boolean} [param1.switchMenu=true] Whether to switch the menu
7919+ */
7920+export function select_selected_character(chid, { switchMenu = true } = {}) {
78997921 //character select
79007922 //console.log('select_selected_character() -- starting with input of -- ' + chid + ' (name:' + characters[chid].name + ')');
79017923 select_rm_create({ switchMenu });
79027924 switchMenu && setMenuType('character_edit');
79037925 $('#delete_button').css('display', 'flex');
79047926 $('#export_button').css('display', 'flex');
7905- var display_name = characters[chid].name;
79067927
79077928 //create text poles
79087929 $('#rm_button_back').css('display', 'none');
@@ -7917,7 +7938,7 @@ export function select_selected_character(chid) {
79177938
79187939 // Don't update the navbar name if we're peeking the group member defs
79197940 if (!selected_group) {
79207941 $('#rm_button_selected_ch').children('h2').text(display_namecharacters[chid].name);
79217942 }
79227943
79237944 $('#add_avatar_button').val('');
@@ -7948,22 +7969,20 @@ export function select_selected_character(chid) {
79487969 $('#chat_import_avatar_url').val(characters[chid].avatar);
79497970 $('#chat_import_character_name').val(characters[chid].name);
79507971 $('#character_json_data').val(characters[chid].json_data);
7951- let this_avatar = default_avatar;
7952- if (characters[chid].avatar != 'none') {
7953- this_avatar = getThumbnailUrl('avatar', characters[chid].avatar);
7954- }
79557972
79567973 updateFavButtonState(characters[chid].fav || characters[chid].fav == 'true');
79577974
7958- $('#avatar_load_preview').attr('src', this_avatar);
7975+ const avatarUrl = characters[chid].avatar != 'none' ? getThumbnailUrl('avatar', characters[chid].avatar) : default_avatar;
79597976 $('#name_divavatar_load_preview').removeClassattr('displayBlocksrc', avatarUrl);
7960- $('#name_div').addClass('displayNone');
7961- $('#renameCharButton').css('display', '');
79627977 $('.open_alternate_greetings').data('chid', chid);
79637978 $('#set_character_world').data('chid', chid);
79647979 setWorldInfoButtonClass(chid);
79657980 checkEmbeddedWorld(chid);
79667981
7982+ $('#name_div').removeClass('displayBlock');
7983+ $('#name_div').addClass('displayNone');
7984+ $('#renameCharButton').css('display', '');
7985+
79677986 $('#form_create').attr('actiontype', 'editcharacter');
79687987 $('.form_create_bottom_buttons_block .chat_lorebook_button').show();
79697988
@@ -7975,8 +7994,13 @@ export function select_selected_character(chid) {
79757994 saveSettingsDebounced();
79767995}
79777996
7978-function select_rm_create() {
7997+/**
7979- setMenuType('create');
7998+ * Selects the right menu for creating a new character.
7999+ * @param {object} [options] Options for the switch
8000+ * @param {boolean} [options.switchMenu=true] Whether to switch the menu
8001+ */
8002+function select_rm_create({ switchMenu = true } = {}) {
8003+ switchMenu && setMenuType('create');
79808004
79818005 //console.log('select_rm_Create() -- selected button: '+selected_button);
79828006 if (selected_button == 'create') {
@@ -7986,7 +8010,7 @@ function select_rm_create() {
79868010 }
79878011 }
79888012
79898013 switchMenu && selectRightMenuWithAnimation('rm_ch_create_block');
79908014
79918015 $('#set_chat_scenario').hide();
79928016 $('#delete_button_div').css('display', 'none');
@@ -9209,6 +9233,17 @@ function swipe_right(_event, { source, repeated } = {}) {
92099233 }
92109234}
92119235
9236+/**
9237+ * @typedef {object} ConnectAPIMap
9238+ * @property {string} selected - API name (e.g. "textgenerationwebui", "openai")
9239+ * @property {string?} [button] - CSS selector for the API button
9240+ * @property {string?} [type] - API type, mostly used by text completion. (e.g. "openrouter")
9241+ * @property {string?} [source] - API source, mostly used by chat completion. (e.g. "openai")
9242+ */
9243+
9244+/**
9245+ * @type {Record<string, ConnectAPIMap>}
9246+ */
92129247export const CONNECT_API_MAP = {
92139248 // Default APIs not contined inside text gen / chat gen
92149249 'kobold': {
public/scripts/custom-request.js+231 -33
@@ -1,20 +1,20 @@
11import { getPresetManager } from './preset-manager.js';
22import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js';
33import { getTextGenServer } from './textgen-settings.js';
4+import { extractReasoningFromData } from './reasoning.js';
5+import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js';
46
57// #region Type Definitions
68/**
79 * @typedef {Object} TextCompletionRequestBase
8- * @property {string} prompt - The text prompt for completion
910 * @property {number} max_tokens - Maximum number of tokens to generate
1011 * @property {string} [model] - Optional model name
1112 * @property {string} api_type - Type of API to use
1213 * @property {string} [api_server] - Optional API server URL
1314 * @property {number} [temperature] - Optional temperature parameter
15+ * @property {number} [min_p] - Optional min_p parameter
1416 */
1517
16-/** @typedef {Record<string, any> & TextCompletionRequestBase} TextCompletionRequest */
17-
1818/**
1919 * @typedef {Object} TextCompletionPayloadBase
2020 * @property {string} prompt - The text prompt for completion
@@ -44,6 +44,13 @@ import { getTextGenServer } from './textgen-settings.js';
4444 */
4545
4646/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */
47+
48+/**
49+ * @typedef {Object} ExtractedData
50+ * @property {string} content - Extracted content.
51+ * @property {string} reasoning - Extracted reasoning.
52+ */
53+
4754// #endregion
4855
4956/**
@@ -53,11 +60,11 @@ export class TextCompletionService {
5360 static TYPE = 'textgenerationwebui';
5461
5562 /**
56- * @param {TextCompletionRequest} custom
63+ * @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
5764 * @returns {TextCompletionPayload}
5865 */
5966 static createRequestData({ prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
6067 returnconst payload = {
6168 ...props,
6269 prompt,
6370 max_tokens,
@@ -66,15 +73,25 @@ export class TextCompletionService {
6673 api_type,
6774 api_server: api_server ?? getTextGenServer(api_type),
6875 temperature,
76+ min_p,
6977 stream: false,
7078 };
79+
80+ // Remove undefined values to avoid API errors
81+ Object.keys(payload).forEach(key => {
82+ if (payload[key] === undefined) {
83+ delete payload[key];
84+ }
85+ });
86+
87+ return payload;
7188 }
7289
7390 /**
7491 * Sends a text completion request to the specified server
7592 * @param {TextCompletionPayload} data Request data
7693 * @param {boolean?} extractData Extract message from the response. Default true
7794 * @returns {Promise<stringExtractedData | any>} Extracted data or the raw response
7895 * @throws {Error}
7996 */
8097 static async sendRequest(data, extractData = true) {
@@ -91,31 +108,150 @@ export class TextCompletionService {
91108 throw json;
92109 }
93110
94- return extractData ? extractMessageFromData(json, this.TYPE) : json;
111+ if (!extractData) {
112+ return json;
113+ }
114+
115+ return {
116+ content: extractMessageFromData(json, this.TYPE),
117+ reasoning: extractReasoningFromData(json, {
118+ mainApi: this.TYPE,
119+ textGenType: data.api_type,
120+ ignoreShowThoughts: true,
121+ }),
122+ };
95123 }
96124
97125 /**
98- * @param {string} presetName
126+ * Process and send a text completion request with optional preset & instruct
99- * @param {TextCompletionRequest} custom
127+ * @param {Record<string, any> & TextCompletionRequestBase & {prompt: (ChatCompletionMessage & {ignoreInstruct?: boolean})[] |string}} custom
100128 * @param {boolean?Object} extractData Extract message from theoptions response.- DefaultConfiguration trueoptions
101- * @returns {Promise<string | any>} Extracted data or the raw response
129+ * @param {string?} [options.presetName] - Name of the preset to use for generation settings
130+ * @param {string?} [options.instructName] - Name of instruct preset for message formatting
131+ * @param {boolean} extractData - Whether to extract structured data from response
132+ * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
102133 * @throws {Error}
103134 */
104- static async sendRequestWithPreset(presetName, custom, extractData = true) {
135+ static async processRequest(
136+ custom,
137+ options = {},
138+ extractData = true,
139+ ) {
140+ const { presetName, instructName } = options;
141+ let requestData = { ...custom };
142+ const prompt = custom.prompt;
143+
144+ // Apply generation preset if specified
145+ if (presetName) {
105146 const presetManager = getPresetManager(this.TYPE);
106147 if (!presetManager) {
107- throw new Error('Preset manager not found');
148+ const preset = presetManager.getCompletionPresetByName(presetName);
149+ if (preset) {
150+ // Convert preset to payload and merge with custom parameters
151+ const presetPayload = this.presetToGeneratePayload(preset, {});
152+ requestData = { ...presetPayload, ...requestData };
153+ } else {
154+ console.warn(`Preset "${presetName}" not found, continuing with default settings`);
155+ }
156+ } else {
157+ console.warn('Preset manager not found, continuing with default settings');
158+ }
108159 }
109160
110- const preset = presetManager.getCompletionPresetByName(presetName);
161+ // Handle instruct formatting if requested
111162 if (!presetArray.isArray(prompt) && instructName) {
112163 throwconst newinstructPresetManager Error= getPresetManager('Preset not foundinstruct');
164+ let instructPreset = instructPresetManager?.getCompletionPresetByName(instructName);
165+ if (instructPreset) {
166+ // Clone the preset to avoid modifying the original
167+ instructPreset = structuredClone(instructPreset);
168+ instructPreset.macro = false;
169+ instructPreset.names_behavior = names_behavior_types.NONE;
170+
171+ // Format messages using instruct formatting
172+ const formattedMessages = [];
173+ for (const message of prompt) {
174+ let messageContent = message.content;
175+ if (!message.ignoreInstruct) {
176+ messageContent = formatInstructModeChat(
177+ message.role,
178+ message.content,
179+ message.role === 'user',
180+ false,
181+ undefined,
182+ undefined,
183+ undefined,
184+ undefined,
185+ instructPreset,
186+ );
187+
188+ // Add prompt formatting for the last message
189+ if (message === prompt[prompt.length - 1]) {
190+ messageContent += formatInstructModePrompt(
191+ undefined,
192+ false,
193+ undefined,
194+ undefined,
195+ undefined,
196+ false,
197+ false,
198+ instructPreset,
199+ );
200+ }
201+ }
202+ formattedMessages.push(messageContent);
203+ }
204+ requestData.prompt = formattedMessages.join('');
205+ if (instructPreset.output_suffix) {
206+ requestData.stop = [instructPreset.output_suffix];
207+ requestData.stopping_strings = [instructPreset.output_suffix];
208+ }
209+ } else {
210+ console.warn(`Instruct preset "${instructName}" not found, using basic formatting`);
211+ requestData.prompt = prompt.map(x => x.content).join('\n\n');
212+ }
213+ } else if (typeof prompt === 'string') {
214+ requestData.prompt = prompt;
215+ } else {
216+ requestData.prompt = prompt.map(x => x.content).join('\n\n');
113217 }
114218
115- const data = this.createRequestData({ ...preset, ...custom });
219+ // @ts-ignore
220+ const data = this.createRequestData(requestData);
116221
117222 return await this.sendRequest(data, extractData);
118223 }
224+
225+ /**
226+ * Converts a preset to a valid text completion payload.
227+ * Only supports temperature.
228+ * @param {Object} preset - The preset configuration
229+ * @param {Object} customPreset - Additional parameters to override preset values
230+ * @returns {Object} - Formatted payload for text completion API
231+ */
232+ static presetToGeneratePayload(preset, customPreset = {}) {
233+ if (!preset || typeof preset !== 'object') {
234+ throw new Error('Invalid preset: must be an object');
235+ }
236+
237+ // Merge preset with custom parameters
238+ const settings = { ...preset, ...customPreset };
239+
240+ // Initialize base payload with common parameters
241+ let payload = {
242+ 'temperature': settings.temp ? Number(settings.temp) : undefined,
243+ 'min_p': settings.min_p ? Number(settings.min_p) : undefined,
244+ };
245+
246+ // Remove undefined values to avoid API errors
247+ Object.keys(payload).forEach(key => {
248+ if (payload[key] === undefined) {
249+ delete payload[key];
250+ }
251+ });
252+
253+ return payload;
254+ }
119255}
120256
121257/**
@@ -129,7 +265,7 @@ export class ChatCompletionService {
129265 * @returns {ChatCompletionPayload}
130266 */
131267 static createRequestData({ messages, model, chat_completion_source, max_tokens, temperature, ...props }) {
132268 returnconst payload = {
133269 ...props,
134270 messages,
135271 model,
@@ -138,13 +274,22 @@ export class ChatCompletionService {
138274 temperature,
139275 stream: false,
140276 };
277+
278+ // Remove undefined values to avoid API errors
279+ Object.keys(payload).forEach(key => {
280+ if (payload[key] === undefined) {
281+ delete payload[key];
282+ }
283+ });
284+
285+ return payload;
141286 }
142287
143288 /**
144289 * Sends a chat completion request
145290 * @param {ChatCompletionPayload} data Request data
146291 * @param {boolean?} extractData Extract message from the response. Default true
147292 * @returns {Promise<stringExtractedData | any>} Extracted data or the raw response
148293 * @throws {Error}
149294 */
150295 static async sendRequest(data, extractData = true) {
@@ -161,29 +306,82 @@ export class ChatCompletionService {
161306 throw json;
162307 }
163308
164- return extractData ? extractMessageFromData(json, this.TYPE) : json;
309+ if (!extractData) {
310+ return json;
311+ }
312+
313+ return {
314+ content: extractMessageFromData(json, this.TYPE),
315+ reasoning: extractReasoningFromData(json, {
316+ mainApi: this.TYPE,
317+ textGenType: data.chat_completion_source,
318+ ignoreShowThoughts: true,
319+ }),
320+ };
165321 }
166322
167323 /**
168- * @param {string} presetName
324+ * Process and send a chat completion request with optional preset
169325 * @param {ChatCompletionPayload} custom
170326 * @param {booleanObject} extractData Extract message from theoptions response.- DefaultConfiguration trueoptions
171- * @returns {Promise<string | any>} Extracted data or the raw response
327+ * @param {string?} [options.presetName] - Name of the preset to use for generation settings
328+ * @param {boolean} extractData - Whether to extract structured data from response
329+ * @returns {Promise<ExtractedData | any>} Extracted data or the raw response
172330 * @throws {Error}
173331 */
174332 static async sendRequestWithPresetprocessRequest(presetNamecustom, customoptions, extractData = true) {
175333 const presetManager{ presetName } = getPresetManager(this.TYPE)options;
176- if (!presetManager) {
334+ let requestData = { ...custom };
177- throw new Error('Preset manager not found');
178- }
179335
336+ // Apply generation preset if specified
337+ if (presetName) {
338+ const presetManager = getPresetManager(this.TYPE);
339+ if (presetManager) {
180340 const preset = presetManager.getCompletionPresetByName(presetName);
181341 if (!preset) {
182- throw new Error('Preset not found');
342+ // Convert preset to payload and merge with custom parameters
343+ const presetPayload = this.presetToGeneratePayload(preset, {});
344+ requestData = { ...presetPayload, ...requestData };
345+ } else {
346+ console.warn(`Preset "${presetName}" not found, continuing with default settings`);
347+ }
348+ } else {
349+ console.warn('Preset manager not found, continuing with default settings');
350+ }
183351 }
184352
185353 const data = this.createRequestData({ ...preset, ...custom }requestData);
186354
187355 return await this.sendRequest(data, extractData);
188356 }
357+
358+ /**
359+ * Converts a preset to a valid chat completion payload
360+ * Only supports temperature.
361+ * @param {Object} preset - The preset configuration
362+ * @param {Object} customParams - Additional parameters to override preset values
363+ * @returns {Object} - Formatted payload for chat completion API
364+ */
365+ static presetToGeneratePayload(preset, customParams = {}) {
366+ if (!preset || typeof preset !== 'object') {
367+ throw new Error('Invalid preset: must be an object');
368+ }
369+
370+ // Merge preset with custom parameters
371+ const settings = { ...preset, ...customParams };
372+
373+ // Initialize base payload with common parameters
374+ const payload = {
375+ temperature: settings.temperature ? Number(settings.temperature) : undefined,
376+ };
377+
378+ // Remove undefined values to avoid API errors
379+ Object.keys(payload).forEach(key => {
380+ if (payload[key] === undefined) {
381+ delete payload[key];
382+ }
383+ });
384+
385+ return payload;
386+ }
189387}
public/scripts/extensions/connection-manager/index.js+28 -6
@@ -1,4 +1,4 @@
11import { DOMPurify, Fuse } from '../../../lib.js';
22
33import { event_types, eventSource, main_api, saveSettingsDebounced } from '../../../script.js';
44import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
@@ -267,9 +267,14 @@ async function createConnectionProfile(forceName = null) {
267267 });
268268 const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
269269 const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
270270 constlet name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName, { rows: 2 });
271-
271+ // If it's cancelled, it will be false
272+ if (!name) {
273+ return null;
274+ }
275+ name = DOMPurify.sanitize(String(name));
272276 if (!name) {
277+ toastr.error('Name cannot be empty.');
273278 return null;
274279 }
275280
@@ -303,7 +308,8 @@ async function deleteConnectionProfile() {
303308 return;
304309 }
305310
306311 const nameprofile = extension_settings.connectionManager.profiles[index].name;
312+ const name = profile.name;
307313 const confirm = await Popup.show.confirm(t`Are you sure you want to delete the selected profile?`, name);
308314
309315 if (!confirm) {
@@ -313,6 +319,8 @@ async function deleteConnectionProfile() {
313319 extension_settings.connectionManager.profiles.splice(index, 1);
314320 extension_settings.connectionManager.selectedProfile = null;
315321 saveSettingsDebounced();
322+
323+ await eventSource.emit(event_types.CONNECTION_PROFILE_DELETED, profile);
316324}
317325
318326/**
@@ -512,6 +520,7 @@ async function renderDetailsContent(detailsContent) {
512520 saveSettingsDebounced();
513521 renderConnectionProfiles(profiles);
514522 await renderDetailsContent(detailsContent);
523+ await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
515524 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
516525 });
517526
@@ -523,9 +532,11 @@ async function renderDetailsContent(detailsContent) {
523532 console.log('No profile selected');
524533 return;
525534 }
535+ const oldProfile = structuredClone(profile);
526536 await updateConnectionProfile(profile);
527537 await renderDetailsContent(detailsContent);
528538 saveSettingsDebounced();
539+ await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
529540 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
530541 toastr.success('Connection profile updated', '', { timeOut: 1500 });
531542 });
@@ -559,7 +570,7 @@ async function renderDetailsContent(detailsContent) {
559570 return acc;
560571 }, {});
561572 const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
562573 constlet newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
563574 rows: 2,
564575 customButtons: [{
565576 text: t`Save and Update`,
@@ -571,7 +582,13 @@ async function renderDetailsContent(detailsContent) {
571582 }],
572583 });
573584
585+ // If it's cancelled, it will be false
586+ if (!newName) {
587+ return;
588+ }
589+ newName = DOMPurify.sanitize(String(newName));
574590 if (!newName) {
591+ toastr.error('Name cannot be empty.');
575592 return;
576593 }
577594
@@ -584,6 +601,7 @@ async function renderDetailsContent(detailsContent) {
584601 return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
585602 }).get();
586603
604+ const oldProfile = structuredClone(profile);
587605 if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
588606 profile.exclude = newExcludeList;
589607 for (const command of newExcludeList) {
@@ -598,10 +616,11 @@ async function renderDetailsContent(detailsContent) {
598616
599617 if (profile.name !== newName) {
600618 toastr.success('Connection profile renamed.');
601619 profile.name = String(newName);
602620 }
603621
604622 saveSettingsDebounced();
623+ await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
605624 renderConnectionProfiles(profiles);
606625 await renderDetailsContent(detailsContent);
607626 });
@@ -704,6 +723,7 @@ async function renderDetailsContent(detailsContent) {
704723 saveSettingsDebounced();
705724 renderConnectionProfiles(profiles);
706725 await renderDetailsContent(detailsContent);
726+ await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
707727 return profile.name;
708728 },
709729 }));
@@ -718,9 +738,11 @@ async function renderDetailsContent(detailsContent) {
718738 toastr.warning('No profile selected.');
719739 return '';
720740 }
741+ const oldProfile = structuredClone(profile);
721742 await updateConnectionProfile(profile);
722743 await renderDetailsContent(detailsContent);
723744 saveSettingsDebounced();
745+ await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
724746 return profile.name;
725747 },
726748 }));
public/scripts/extensions/shared.js+308 -1
@@ -1,5 +1,6 @@
11import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js';
22import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
3+import { t } from '../i18n.js';
34import { oai_settings } from '../openai.js';
45import { SECRET_KEYS, secret_state } from '../secrets.js';
56import { textgen_types, textgenerationwebui_settings } from '../textgen-settings.js';
@@ -273,3 +274,309 @@ export async function getWebLlmContextSize() {
273274 const model = await engine.getCurrentModelInfo();
274275 return model?.context_size;
275276}
277+
278+/**
279+ * It uses the profiles to send a generate request to the API. Doesn't support streaming.
280+ */
281+export class ConnectionManagerRequestService {
282+ static defaultSendRequestParams = {
283+ extractData: true,
284+ includePreset: true,
285+ includeInstruct: true,
286+ };
287+
288+ static getAllowedTypes() {
289+ return {
290+ openai: t`Chat Completion`,
291+ textgenerationwebui: t`Text Completion`,
292+ };
293+ }
294+
295+ /**
296+ * @param {string} profileId
297+ * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
298+ * @param {number} maxTokens
299+ * @param {{extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true
300+ * @returns {Promise<import('../custom-request.js').ExtractedData | any>} Extracted data or the raw response
301+ */
302+ static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {
303+ const { extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom };
304+
305+ const context = SillyTavern.getContext();
306+ if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
307+ throw new Error('Connection Manager is not available');
308+ }
309+
310+ const profile = context.extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
311+ const selectedApiMap = this.validateProfile(profile);
312+
313+ try {
314+ switch (selectedApiMap.selected) {
315+ case 'openai': {
316+ if (!selectedApiMap.source) {
317+ throw new Error(`API type ${selectedApiMap.selected} does not support chat completions`);
318+ }
319+
320+ const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];
321+ return await context.ChatCompletionService.processRequest({
322+ messages,
323+ max_tokens: maxTokens,
324+ model: profile.model,
325+ chat_completion_source: selectedApiMap.source,
326+ }, {
327+ presetName: includePreset ? profile.preset : undefined,
328+ }, extractData);
329+ }
330+ case 'textgenerationwebui': {
331+ if (!selectedApiMap.type) {
332+ throw new Error(`API type ${selectedApiMap.selected} does not support text completions`);
333+ }
334+
335+ return await context.TextCompletionService.processRequest({
336+ prompt,
337+ max_tokens: maxTokens,
338+ model: profile.model,
339+ api_type: selectedApiMap.type,
340+ api_server: profile['api-url'],
341+ }, {
342+ instructName: includeInstruct ? profile.instruct : undefined,
343+ presetName: includePreset ? profile.preset : undefined,
344+ }, extractData);
345+ }
346+ default: {
347+ throw new Error(`Unknown API type ${selectedApiMap.selected}`);
348+ }
349+ }
350+ } catch (error) {
351+ throw new Error('API request failed', { cause: error });
352+ }
353+ }
354+
355+ /**
356+ * Respects allowed types.
357+ * @returns {import('./connection-manager/index.js').ConnectionProfile[]}
358+ */
359+ static getSupportedProfiles() {
360+ const context = SillyTavern.getContext();
361+ if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
362+ throw new Error('Connection Manager is not available');
363+ }
364+
365+ const profiles = context.extensionSettings.connectionManager.profiles;
366+ return profiles.filter((p) => this.isProfileSupported(p));
367+ }
368+
369+ /**
370+ * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
371+ * @returns {boolean}
372+ */
373+ static isProfileSupported(profile) {
374+ if (!profile) {
375+ return false;
376+ }
377+
378+ const apiMap = CONNECT_API_MAP[profile.api];
379+ if (!Object.hasOwn(this.getAllowedTypes(), apiMap.selected)) {
380+ return false;
381+ }
382+
383+ // Some providers not need model, like koboldcpp. But I don't want to check by provider.
384+ switch (apiMap.selected) {
385+ case 'openai':
386+ return !!apiMap.source;
387+ case 'textgenerationwebui':
388+ return !!apiMap.type;
389+ }
390+
391+ return false;
392+ }
393+
394+ /**
395+ * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
396+ * @return {import('../../script.js').ConnectAPIMap}
397+ * @throws {Error}
398+ */
399+ static validateProfile(profile) {
400+ if (!profile) {
401+ throw new Error('Could not find profile.');
402+ }
403+ if (!profile.api) {
404+ throw new Error('Select a connection profile that has an API');
405+ }
406+
407+ const context = SillyTavern.getContext();
408+ const selectedApiMap = context.CONNECT_API_MAP[profile.api];
409+ if (!selectedApiMap) {
410+ throw new Error(`Unknown API type ${profile.api}`);
411+ }
412+ if (!Object.hasOwn(this.getAllowedTypes(), selectedApiMap.selected)) {
413+ throw new Error(`API type ${selectedApiMap.selected} is not supported. Supported types: ${Object.values(this.getAllowedTypes()).join(', ')}`);
414+ }
415+
416+ return selectedApiMap;
417+ }
418+
419+ /**
420+ * Create profiles dropdown and updates select element accordingly. Use onChange, onCreate, unUpdate, onDelete callbacks for custom behaviour. e.g updating extension settings.
421+ * @param {string} selector
422+ * @param {string} initialSelectedProfileId
423+ * @param {(profile?: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onChange - 3 cases. 1- When user selects new profile. 2- When user deletes selected profile. 3- When user updates selected profile.
424+ * @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onCreate
425+ * @param {(oldProfile: import('./connection-manager/index.js').ConnectionProfile, newProfile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} unUpdate
426+ * @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onDelete
427+ */
428+ static handleDropdown(
429+ selector,
430+ initialSelectedProfileId,
431+ onChange = () => { },
432+ onCreate = () => { },
433+ unUpdate = () => { },
434+ onDelete = () => { },
435+ ) {
436+ const context = SillyTavern.getContext();
437+ if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
438+ throw new Error('Connection Manager is not available');
439+ }
440+
441+ /**
442+ * @type {JQuery<HTMLSelectElement>}
443+ */
444+ const dropdown = $(selector);
445+
446+ if (!dropdown || !dropdown.length) {
447+ throw new Error(`Could not find dropdown with selector ${selector}`);
448+ }
449+
450+ dropdown.empty();
451+
452+ // Create default option using document.createElement
453+ const defaultOption = document.createElement('option');
454+ defaultOption.value = '';
455+ defaultOption.textContent = 'Select a Connection Profile';
456+ defaultOption.dataset.i18n = 'Select a Connection Profile';
457+ dropdown.append(defaultOption);
458+
459+ const profiles = context.extensionSettings.connectionManager.profiles;
460+
461+ // Create optgroups using document.createElement
462+ const groups = {};
463+ for (const [apiType, groupLabel] of Object.entries(this.getAllowedTypes())) {
464+ const optgroup = document.createElement('optgroup');
465+ optgroup.label = groupLabel;
466+ groups[apiType] = optgroup;
467+ }
468+
469+ const sortedProfilesByGroup = {};
470+ for (const apiType of Object.keys(this.getAllowedTypes())) {
471+ sortedProfilesByGroup[apiType] = [];
472+ }
473+
474+ for (const profile of profiles) {
475+ if (this.isProfileSupported(profile)) {
476+ const apiMap = CONNECT_API_MAP[profile.api];
477+ if (sortedProfilesByGroup[apiMap.selected]) {
478+ sortedProfilesByGroup[apiMap.selected].push(profile);
479+ }
480+ }
481+ }
482+
483+ // Sort each group alphabetically and add to dropdown
484+ for (const [apiType, groupProfiles] of Object.entries(sortedProfilesByGroup)) {
485+ if (groupProfiles.length === 0) continue;
486+
487+ groupProfiles.sort((a, b) => a.name.localeCompare(b.name));
488+
489+ const group = groups[apiType];
490+ for (const profile of groupProfiles) {
491+ const option = document.createElement('option');
492+ option.value = profile.id;
493+ option.textContent = profile.name;
494+ group.appendChild(option);
495+ }
496+ }
497+
498+ for (const group of Object.values(groups)) {
499+ if (group.children.length > 0) {
500+ dropdown.append(group);
501+ }
502+ }
503+
504+ const selectedProfile = profiles.find((p) => p.id === initialSelectedProfileId);
505+ if (selectedProfile) {
506+ dropdown.val(selectedProfile.id);
507+ }
508+
509+ context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_CREATED, async (profile) => {
510+ const isSupported = this.isProfileSupported(profile);
511+ if (!isSupported) {
512+ return;
513+ }
514+
515+ const group = groups[CONNECT_API_MAP[profile.api].selected];
516+ const option = document.createElement('option');
517+ option.value = profile.id;
518+ option.textContent = profile.name;
519+ group.appendChild(option);
520+
521+ await onCreate(profile);
522+ });
523+
524+ context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_UPDATED, async (oldProfile, newProfile) => {
525+ const currentSelected = dropdown.val();
526+ const isSelectedProfile = currentSelected === oldProfile.id;
527+ await unUpdate(oldProfile, newProfile);
528+
529+ if (!this.isProfileSupported(newProfile)) {
530+ if (isSelectedProfile) {
531+ dropdown.val('');
532+ dropdown.trigger('change');
533+ }
534+ return;
535+ }
536+
537+ const group = groups[CONNECT_API_MAP[newProfile.api].selected];
538+ const oldOption = group.querySelector(`option[value="${oldProfile.id}"]`);
539+ if (oldOption) {
540+ oldOption.remove();
541+ }
542+
543+ const option = document.createElement('option');
544+ option.value = newProfile.id;
545+ option.textContent = newProfile.name;
546+ group.appendChild(option);
547+
548+ if (isSelectedProfile) {
549+ // Ackchyually, we don't need to reselect but what if id changes? It is not possible for now I couldn't stop myself.
550+ dropdown.val(newProfile.id);
551+ dropdown.trigger('change');
552+ }
553+ });
554+
555+ context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_DELETED, async (profile) => {
556+ const currentSelected = dropdown.val();
557+ const isSelectedProfile = currentSelected === profile.id;
558+ if (!this.isProfileSupported(profile)) {
559+ return;
560+ }
561+
562+ const group = groups[CONNECT_API_MAP[profile.api].selected];
563+ const optionToRemove = group.querySelector(`option[value="${profile.id}"]`);
564+ if (optionToRemove) {
565+ optionToRemove.remove();
566+ }
567+
568+ if (isSelectedProfile) {
569+ dropdown.val('');
570+ dropdown.trigger('change');
571+ }
572+
573+ await onDelete(profile);
574+ });
575+
576+ dropdown.on('change', async () => {
577+ const profileId = dropdown.val();
578+ const profile = context.extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
579+ await onChange(profile);
580+ });
581+ }
582+}
public/scripts/instruct-mode.js+35 -31
@@ -320,59 +320,61 @@ export const force_output_sequence = {
320320 * @param {string} name1 User name.
321321 * @param {string} name2 Character name.
322322 * @param {boolean|number} forceOutputSequence Force to use first/last output sequence (if configured).
323+ * @param {InstructSettings} customInstruct Custom instruct mode settings.
323324 * @returns {string} Formatted instruct mode chat message.
324325 */
325326export function formatInstructModeChat(name, mes, isUser, isNarrator, forceAvatar, name1, name2, forceOutputSequence, customInstruct = null) {
326327 letconst includeNamesinstruct = isNarratorstructuredClone(customInstruct ? false :? power_user.instruct.names_behavior === names_behavior_types.ALWAYS);
328+ let includeNames = isNarrator ? false : instruct.names_behavior === names_behavior_types.ALWAYS;
327329
328330 if (!isNarrator && power_user.instruct.names_behavior === names_behavior_types.FORCE && ((selected_group && name !== name1) || (forceAvatar && name !== name1))) {
329331 includeNames = true;
330332 }
331333
332334 function getPrefix() {
333335 if (isNarrator) {
334336 return power_user.instruct.system_same_as_user ? power_user.instruct.input_sequence : power_user.instruct.system_sequence;
335337 }
336338
337339 if (isUser) {
338340 if (forceOutputSequence === force_output_sequence.FIRST) {
339341 return power_user.instruct.first_input_sequence || power_user.instruct.input_sequence;
340342 }
341343
342344 if (forceOutputSequence === force_output_sequence.LAST) {
343345 return power_user.instruct.last_input_sequence || power_user.instruct.input_sequence;
344346 }
345347
346348 return power_user.instruct.input_sequence;
347349 }
348350
349351 if (forceOutputSequence === force_output_sequence.FIRST) {
350352 return power_user.instruct.first_output_sequence || power_user.instruct.output_sequence;
351353 }
352354
353355 if (forceOutputSequence === force_output_sequence.LAST) {
354356 return power_user.instruct.last_output_sequence || power_user.instruct.output_sequence;
355357 }
356358
357359 return power_user.instruct.output_sequence;
358360 }
359361
360362 function getSuffix() {
361363 if (isNarrator) {
362364 return power_user.instruct.system_same_as_user ? power_user.instruct.input_suffix : power_user.instruct.system_suffix;
363365 }
364366
365367 if (isUser) {
366368 return power_user.instruct.input_suffix;
367369 }
368370
369371 return power_user.instruct.output_suffix;
370372 }
371373
372374 let prefix = getPrefix() || '';
373375 let suffix = getSuffix() || '';
374376
375377 if (power_user.instruct.macro) {
376378 prefix = substituteParams(prefix, name1, name2);
377379 prefix = prefix.replace(/{{name}}/gi, name || 'System');
378380
@@ -380,11 +382,11 @@ export function formatInstructModeChat(name, mes, isUser, isNarrator, forceAvata
380382 suffix = suffix.replace(/{{name}}/gi, name || 'System');
381383 }
382384
383385 if (!suffix && power_user.instruct.wrap) {
384386 suffix = '\n';
385387 }
386388
387389 const separator = power_user.instruct.wrap ? '\n' : '';
388390
389391 // Don't include the name if it's empty
390392 const textArray = includeNames && name ? [prefix, `${name}: ${mes}` + suffix] : [prefix, mes + suffix];
@@ -504,30 +506,32 @@ export function formatInstructModeExamples(mesExamplesArray, name1, name2) {
504506 * @param {string} name2 Character name.
505507 * @param {boolean} isQuiet Is quiet mode generation.
506508 * @param {boolean} isQuietToLoud Is quiet to loud generation.
509+ * @param {InstructSettings} customInstruct Custom instruct settings.
507510 * @returns {string} Formatted instruct mode last prompt line.
508511 */
509512export function formatInstructModePrompt(name, isImpersonate, promptBias, name1, name2, isQuiet, isQuietToLoud, customInstruct = null) {
510- const includeNames = name && (power_user.instruct.names_behavior === names_behavior_types.ALWAYS || (!!selected_group && power_user.instruct.names_behavior === names_behavior_types.FORCE)) && !(isQuiet && !isQuietToLoud);
513+ const instruct = structuredClone(customInstruct ?? power_user.instruct);
514+ const includeNames = name && (instruct.names_behavior === names_behavior_types.ALWAYS || (!!selected_group && instruct.names_behavior === names_behavior_types.FORCE)) && !(isQuiet && !isQuietToLoud);
511515
512516 function getSequence() {
513517 // User impersonation prompt
514518 if (isImpersonate) {
515519 return power_user.instruct.input_sequence;
516520 }
517521
518522 // Neutral / system / quiet prompt
519523 // Use a special quiet instruct sequence if defined, or assistant's output sequence otherwise
520524 if (isQuiet && !isQuietToLoud) {
521525 return power_user.instruct.last_system_sequence || power_user.instruct.output_sequence;
522526 }
523527
524528 // Quiet in-character prompt
525529 if (isQuiet && isQuietToLoud) {
526530 return power_user.instruct.last_output_sequence || power_user.instruct.output_sequence;
527531 }
528532
529533 // Default AI response
530534 return power_user.instruct.last_output_sequence || power_user.instruct.output_sequence;
531535 }
532536
533537 let sequence = getSequence() || '';
@@ -536,21 +540,21 @@ export function formatInstructModePrompt(name, isImpersonate, promptBias, name1,
536540 // A hack for Mistral's formatting that has a normal output sequence ending with a space
537541 if (
538542 includeNames &&
539543 power_user.instruct.last_output_sequence &&
540544 power_user.instruct.output_sequence &&
541545 sequence === power_user.instruct.last_output_sequence &&
542546 /\s$/.test(power_user.instruct.output_sequence) &&
543547 !/\s$/.test(power_user.instruct.last_output_sequence)
544548 ) {
545549 nameFiller = power_user.instruct.output_sequence.slice(-1);
546550 }
547551
548552 if (power_user.instruct.macro) {
549553 sequence = substituteParams(sequence, name1, name2);
550554 sequence = sequence.replace(/{{name}}/gi, name || 'System');
551555 }
552556
553557 const separator = power_user.instruct.wrap ? '\n' : '';
554558 let text = includeNames ? (separator + sequence + separator + nameFiller + `${name}:`) : (separator + sequence);
555559
556560 // Quiet prompt already has a newline at the end
@@ -562,7 +566,7 @@ export function formatInstructModePrompt(name, isImpersonate, promptBias, name1,
562566 text += (includeNames ? promptBias : (separator + promptBias.trimStart()));
563567 }
564568
565569 return (power_user.instruct.wrap ? text.trimEnd() : text) + (includeNames ? '' : separator);
566570}
567571
568572/**
public/scripts/openai.js+1 -1
@@ -2048,7 +2048,7 @@ async function sendOpenAIRequest(type, messages, signal) {
20482048 delete generate_data.stop;
20492049 delete generate_data.logprobs;
20502050 }
20512051 if (isOAI && oai_settings.openai_model.includes('gpt-4.5-preview') || isOpenRouter && oai_settings.openrouter_model.includes('gpt-4.5-preview')) {
20522052 delete generate_data.logprobs;
20532053 }
20542054
public/scripts/power-user.js+2 -0
@@ -218,7 +218,9 @@ let power_user = {
218218 system_sequence: '',
219219 system_suffix: '',
220220 last_system_sequence: '',
221+ first_input_sequence: '',
221222 first_output_sequence: '',
223+ last_input_sequence: '',
222224 last_output_sequence: '',
223225 system_sequence_prefix: '',
224226 system_sequence_suffix: '',
public/scripts/reasoning.js+10 -5
@@ -57,19 +57,24 @@ function toggleReasoningAutoExpand() {
5757 * @param {object} data Response data
5858 * @returns {string} Extracted reasoning
5959 */
6060export function extractReasoningFromData(data), {
61- switch (main_api) {
61+ mainApi = null,
62+ ignoreShowThoughts = false,
63+ textGenType = null,
64+ chatCompletionSource = null
65+} = {}) {
66+ switch (mainApi ?? main_api) {
6267 case 'textgenerationwebui':
6368 switch (textGenType ?? textgenerationwebui_settings.type) {
6469 case textgen_types.OPENROUTER:
6570 return data?.choices?.[0]?.reasoning ?? '';
6671 }
6772 break;
6873
6974 case 'openai':
7075 if (!ignoreShowThoughts && !oai_settings.show_thoughts) break;
7176
7277 switch (chatCompletionSource ?? oai_settings.chat_completion_source) {
7378 case chat_completion_sources.DEEPSEEK:
7479 return data?.choices?.[0]?.message?.reasoning_content ?? '';
7580 case chat_completion_sources.OPENROUTER:
public/scripts/st-context.js+2 -0
@@ -80,6 +80,7 @@ import { timestampToMoment, uuidv4 } from './utils.js';
8080import { getGlobalVariable, getLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';
8181import { convertCharacterBook, loadWorldInfo, saveWorldInfo, updateWorldInfoList } from './world-info.js';
8282import { ChatCompletionService, TextCompletionService } from './custom-request.js';
83+import { ConnectionManagerRequestService } from './extensions/shared.js';
8384import { updateReasoningUI, parseReasoningFromString } from './reasoning.js';
8485
8586export function getContext() {
@@ -215,6 +216,7 @@ export function getContext() {
215216 clearChat,
216217 ChatCompletionService,
217218 TextCompletionService,
219+ ConnectionManagerRequestService,
218220 updateReasoningUI,
219221 parseReasoningFromString,
220222 unshallowCharacter,
public/scripts/textgen-settings.js+1 -1
@@ -86,7 +86,7 @@ const OOBA_DEFAULT_ORDER = [
8686 'encoder_repetition_penalty',
8787 'no_repeat_ngram',
8888];
8989export const APHRODITE_DEFAULT_ORDER = [
9090 'dry',
9191 'penalties',
9292 'no_repeat_ngram',
src/endpoints/tokenizers.js+1 -1
@@ -411,7 +411,7 @@ export function getTokenizerModel(requestModel) {
411411 return 'gpt-4o';
412412 }
413413
414414 if (requestModel.includes('gpt-4.5-preview')) {
415415 return 'gpt-4o';
416416 }
417417