Merge branch 'staging' into feat/ext-manager-toolbar

572b60d0c13dbf3ba4a2ab47c0a80f6905d3c8d1

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

12 files changed, +290 -89Showing whitespace changes
.github/workflows/issues-updates-on-merge.yml+3 -3
@@ -36,10 +36,10 @@ jobs:
36 for ISSUE in $(echo $issues | jq -r '.[]'); do36 for ISSUE in $(echo $issues | jq -r '.[]'); do
37 if [ "${{ github.ref }}" == "refs/heads/staging" ]; then37 if [ "${{ github.ref }}" == "refs/heads/staging" ]; then
38 LABEL="✅ Done (staging)"38 LABEL="✅ Done (staging)"
39 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "$LABEL"39 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "$LABEL" --remove-label "🧑‍💻 In Progress"
40 elif [ "${{ github.ref }}" == "refs/heads/release" ]; then40 elif [ "${{ github.ref }}" == "refs/heads/release" ]; then
41 LABEL="✅ Done"41 LABEL="✅ Done"
42 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "$LABEL"42 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "$LABEL" --remove-label "🧑‍💻 In Progress"
43 fi43 fi
44 echo "Added label '$LABEL' to issue #$ISSUE"44 echo "Added label '$LABEL' (and removed '🧑‍💻 In Progress' if present) in issue #$ISSUE"
45 done45 done
.github/workflows/pr-auto-manager.yml+2 -2
@@ -262,6 +262,6 @@ jobs:
262 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}262 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
263 run: |263 run: |
264 for ISSUE in $(echo $final_issues | jq -r '.[]'); do264 for ISSUE in $(echo $final_issues | jq -r '.[]'); do
265 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "✅ Done (staging)"265 gh issue edit $ISSUE -R ${{ github.repository }} --add-label "✅ Done (staging)" --remove-label "🧑‍💻 In Progress"
266 echo "Added label '✅ Done (staging)' to issue #$ISSUE"266 echo "Added label '✅ Done (staging)' (and removed '🧑‍💻 In Progress' if present) in issue #$ISSUE"
267 done267 done
public/index.html+1 -0
@@ -5998,6 +5998,7 @@
5998 </div>5998 </div>
5999 </div>5999 </div>
6000 </div>6000 </div>
6001 <i class="menu_button move_entry_button fa-solid fa-right-left" title="Move Entry to Another Lorebook" data-i18n="[title]Move Entry to Another Lorebook"></i>
6001 <i class="menu_button duplicate_entry_button fa-solid fa-paste" title="Duplicate world info entry" data-i18n="[title]Duplicate world info entry" type="submit" value=""></i>6002 <i class="menu_button duplicate_entry_button fa-solid fa-paste" title="Duplicate world info entry" data-i18n="[title]Duplicate world info entry" type="submit" value=""></i>
6002 <i class="menu_button delete_entry_button fa-solid fa-trash-can" title="Delete world info entry" data-i18n="[title]Delete world info entry" type="submit" value=""></i>6003 <i class="menu_button delete_entry_button fa-solid fa-trash-can" title="Delete world info entry" data-i18n="[title]Delete world info entry" type="submit" value=""></i>
6003 </div>6004 </div>
public/scripts/PromptManager.js+2 -30
@@ -1,6 +1,6 @@
1'use strict';1'use strict';
22
3import { DOMPurify, Popper } from '../lib.js';3import { DOMPurify } from '../lib.js';
44
5import { event_types, eventSource, is_send_press, main_api, substituteParams } from '../script.js';5import { event_types, eventSource, is_send_press, main_api, substituteParams } from '../script.js';
6import { is_group_generating } from './group-chats.js';6import { is_group_generating } from './group-chats.js';
@@ -1440,36 +1440,8 @@ class PromptManager {
1440 footerDiv.querySelector('select').selectedIndex = selectedPromptIndex;1440 footerDiv.querySelector('select').selectedIndex = selectedPromptIndex;
14411441
1442 // Add prompt export dialogue and options1442 // Add prompt export dialogue and options
1443
1444 const exportForCharacter = await renderTemplateAsync('promptManagerExportForCharacter');
1445 const exportPopup = await renderTemplateAsync('promptManagerExportPopup', { isGlobalStrategy: 'global' === this.configuration.promptOrder.strategy, exportForCharacter });
1446 rangeBlockDiv.insertAdjacentHTML('beforeend', exportPopup);
1447
1448 // Destroy previous popper instance if it exists
1449 if (this.exportPopper) {
1450 this.exportPopper.destroy();
1451 }
1452
1453 this.exportPopper = Popper.createPopper(
1454 document.getElementById('prompt-manager-export'),
1455 document.getElementById('prompt-manager-export-format-popup'),
1456 { placement: 'bottom' },
1457 );
1458
1459 const showExportSelection = () => {
1460 const popup = document.getElementById('prompt-manager-export-format-popup');
1461 const show = popup.hasAttribute('data-show');
1462
1463 if (show) popup.removeAttribute('data-show');
1464 else popup.setAttribute('data-show', '');
1465
1466 this.exportPopper.update();
1467 };
1468
1469 footerDiv.querySelector('#prompt-manager-import').addEventListener('click', this.handleImport);1443 footerDiv.querySelector('#prompt-manager-import').addEventListener('click', this.handleImport);
1470 footerDiv.querySelector('#prompt-manager-export').addEventListener('click', showExportSelection);1444 footerDiv.querySelector('#prompt-manager-export').addEventListener('click', this.handleFullExport);
1471 rangeBlockDiv.querySelector('.export-promptmanager-prompts-full').addEventListener('click', this.handleFullExport);
1472 rangeBlockDiv.querySelector('.export-promptmanager-prompts-character')?.addEventListener('click', this.handleCharacterExport);
1473 }1445 }
1474 }1446 }
14751447
public/scripts/custom-request.js+67 -8
@@ -2,7 +2,7 @@ import { getPresetManager } from './preset-manager.js';
2import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js';2import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js';
3import { getTextGenServer } from './textgen-settings.js';3import { getTextGenServer } from './textgen-settings.js';
4import { extractReasoningFromData } from './reasoning.js';4import { extractReasoningFromData } from './reasoning.js';
5import { formatInstructModeChat, formatInstructModePrompt, names_behavior_types } from './instruct-mode.js';5import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences, names_behavior_types } from './instruct-mode.js';
6import { getStreamingReply, tryParseStreamingError } from './openai.js';6import { getStreamingReply, tryParseStreamingError } from './openai.js';
7import EventSourceStream from './sse-stream.js';7import EventSourceStream from './sse-stream.js';
88
@@ -190,6 +190,7 @@ export class TextCompletionService {
190 * @param {Object} options - Configuration options190 * @param {Object} options - Configuration options
191 * @param {string?} [options.presetName] - Name of the preset to use for generation settings191 * @param {string?} [options.presetName] - Name of the preset to use for generation settings
192 * @param {string?} [options.instructName] - Name of instruct preset for message formatting192 * @param {string?} [options.instructName] - Name of instruct preset for message formatting
193 * @param {Partial<InstructSettings>?} [options.instructSettings] - Override instruct settings
193 * @param {boolean} extractData - Whether to extract structured data from response194 * @param {boolean} extractData - Whether to extract structured data from response
194 * @param {AbortSignal?} [signal]195 * @param {AbortSignal?} [signal]
195 * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator196 * @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
@@ -222,15 +223,20 @@ export class TextCompletionService {
222 }223 }
223 }224 }
224225
226
227 /** @type {InstructSettings | undefined} */
228 let instructPreset;
225 // Handle instruct formatting if requested229 // Handle instruct formatting if requested
226 if (Array.isArray(prompt) && instructName) {230 if (Array.isArray(prompt) && instructName) {
227 const instructPresetManager = getPresetManager('instruct');231 const instructPresetManager = getPresetManager('instruct');
228 let instructPreset = instructPresetManager?.getCompletionPresetByName(instructName);232 instructPreset = instructPresetManager?.getCompletionPresetByName(instructName);
229 if (instructPreset) {233 if (instructPreset) {
230 // Clone the preset to avoid modifying the original234 // Clone the preset to avoid modifying the original
231 instructPreset = structuredClone(instructPreset);235 instructPreset = structuredClone(instructPreset);
232 instructPreset.macro = false;
233 instructPreset.names_behavior = names_behavior_types.NONE;236 instructPreset.names_behavior = names_behavior_types.NONE;
237 if (options.instructSettings) {
238 Object.assign(instructPreset, options.instructSettings);
239 }
234240
235 // Format messages using instruct formatting241 // Format messages using instruct formatting
236 const formattedMessages = [];242 const formattedMessages = [];
@@ -266,10 +272,9 @@ export class TextCompletionService {
266 formattedMessages.push(messageContent);272 formattedMessages.push(messageContent);
267 }273 }
268 requestData.prompt = formattedMessages.join('');274 requestData.prompt = formattedMessages.join('');
269 if (instructPreset.output_suffix) {275 const stoppingStrings = getInstructStoppingSequences({ customInstruct: instructPreset, useStopStrings: false });
270 requestData.stop = [instructPreset.output_suffix];276 requestData.stop = stoppingStrings;
271 requestData.stopping_strings = [instructPreset.output_suffix];277 requestData.stopping_strings = stoppingStrings;
272 }
273 } else {278 } else {
274 console.warn(`Instruct preset "${instructName}" not found, using basic formatting`);279 console.warn(`Instruct preset "${instructName}" not found, using basic formatting`);
275 requestData.prompt = prompt.map(x => x.content).join('\n\n');280 requestData.prompt = prompt.map(x => x.content).join('\n\n');
@@ -283,7 +288,61 @@ export class TextCompletionService {
283 // @ts-ignore288 // @ts-ignore
284 const data = this.createRequestData(requestData);289 const data = this.createRequestData(requestData);
285290
286 return await this.sendRequest(data, extractData, signal);291 const response = await this.sendRequest(data, extractData, signal);
292 // Remove stopping strings from the end
293 if (!data.stream && extractData) {
294 /** @type {ExtractedData} */
295 // @ts-ignore
296 const extractedData = response;
297
298 let message = extractedData.content;
299
300 message = message.replace(/[^\S\r\n]+$/gm, '');
301
302 if (requestData.stopping_strings) {
303 for (const stoppingString of requestData.stopping_strings) {
304 if (stoppingString.length) {
305 for (let j = stoppingString.length; j > 0; j--) {
306 if (message.slice(-j) === stoppingString.slice(0, j)) {
307 message = message.slice(0, -j);
308 break;
309 }
310 }
311 }
312 }
313 }
314
315 if (instructPreset) {
316 [
317 instructPreset.stop_sequence,
318 instructPreset.input_sequence,
319 ].forEach(sequence => {
320 if (sequence?.trim()) {
321 const index = message.indexOf(sequence);
322 if (index !== -1) {
323 message = message.substring(0, index);
324 }
325 }
326 });
327
328 [
329 instructPreset.output_sequence,
330 instructPreset.last_output_sequence,
331 ].forEach(sequences => {
332 if (sequences) {
333 sequences.split('\n')
334 .filter(line => line.trim() !== '')
335 .forEach(line => {
336 message = message.replaceAll(line, '');
337 });
338 }
339 });
340 }
341
342 extractedData.content = message;
343 }
344
345 return response;
287 }346 }
288347
289 /**348 /**
public/scripts/extensions/shared.js+10 -2
@@ -285,6 +285,7 @@ export class ConnectionManagerRequestService {
285 extractData: true,285 extractData: true,
286 includePreset: true,286 includePreset: true,
287 includeInstruct: true,287 includeInstruct: true,
288 instructSettings: {},
288 };289 };
289290
290 static getAllowedTypes() {291 static getAllowedTypes() {
@@ -298,11 +299,17 @@ export class ConnectionManagerRequestService {
298 * @param {string} profileId299 * @param {string} profileId
299 * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt300 * @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
300 * @param {number} maxTokens301 * @param {number} maxTokens
301 * @param {{stream?: boolean, signal?: AbortSignal, extractData?: boolean, includePreset?: boolean, includeInstruct?: boolean}} custom - default values are true302 * @param {Object} custom
303 * @param {boolean?} [custom.stream=false]
304 * @param {AbortSignal?} [custom.signal]
305 * @param {boolean?} [custom.extractData=true]
306 * @param {boolean?} [custom.includePreset=true]
307 * @param {boolean?} [custom.includeInstruct=true]
308 * @param {Partial<InstructSettings>?} [custom.instructSettings] Override instruct settings
302 * @returns {Promise<import('../custom-request.js').ExtractedData | (() => AsyncGenerator<import('../custom-request.js').StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator309 * @returns {Promise<import('../custom-request.js').ExtractedData | (() => AsyncGenerator<import('../custom-request.js').StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
303 */310 */
304 static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {311 static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams) {
305 const { stream, signal, extractData, includePreset, includeInstruct } = { ...this.defaultSendRequestParams, ...custom };312 const { stream, signal, extractData, includePreset, includeInstruct, instructSettings } = { ...this.defaultSendRequestParams, ...custom };
306313
307 const context = SillyTavern.getContext();314 const context = SillyTavern.getContext();
308 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {315 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
@@ -346,6 +353,7 @@ export class ConnectionManagerRequestService {
346 }, {353 }, {
347 instructName: includeInstruct ? profile.instruct : undefined,354 instructName: includeInstruct ? profile.instruct : undefined,
348 presetName: includePreset ? profile.preset : undefined,355 presetName: includePreset ? profile.preset : undefined,
356 instructSettings: includeInstruct ? instructSettings : undefined,
349 }, extractData, signal);357 }, extractData, signal);
350 }358 }
351 default: {359 default: {
public/scripts/instruct-mode.js+18 -12
@@ -243,9 +243,14 @@ export function autoSelectInstructPreset(modelId) {
243243
244/**244/**
245 * Converts instruct mode sequences to an array of stopping strings.245 * Converts instruct mode sequences to an array of stopping strings.
246 * @param {Object} options
247 * @param {InstructSettings?} [options.customInstruct=null] - Custom instruct settings.
248 * @param {boolean?} [options.useStopStrings] - Decides whether to use "Chat Start" and "Example Separator"
246 * @returns {string[]} Array of instruct mode stopping strings.249 * @returns {string[]} Array of instruct mode stopping strings.
247 */250 */
248export function getInstructStoppingSequences() {251export function getInstructStoppingSequences({ customInstruct = null, useStopStrings = null } = {}) {
252 const instruct = structuredClone(customInstruct ?? power_user.instruct);
253
249 /**254 /**
250 * Adds instruct mode sequence to the result array.255 * Adds instruct mode sequence to the result array.
251 * @param {string} sequence Sequence string.256 * @param {string} sequence Sequence string.
@@ -254,7 +259,7 @@ export function getInstructStoppingSequences() {
254 function addInstructSequence(sequence) {259 function addInstructSequence(sequence) {
255 // Cohee: oobabooga's textgen always appends newline before the sequence as a stopping string260 // Cohee: oobabooga's textgen always appends newline before the sequence as a stopping string
256 // But it's a problem for Metharme which doesn't use newlines to separate them.261 // But it's a problem for Metharme which doesn't use newlines to separate them.
257 const wrap = (s) => power_user.instruct.wrap ? '\n' + s : s;262 const wrap = (s) => instruct.wrap ? '\n' + s : s;
258 // Sequence must be a non-empty string263 // Sequence must be a non-empty string
259 if (typeof sequence === 'string' && sequence.length > 0) {264 if (typeof sequence === 'string' && sequence.length > 0) {
260 // If sequence is just a whitespace or newline - we don't want to make it a stopping string265 // If sequence is just a whitespace or newline - we don't want to make it a stopping string
@@ -262,7 +267,7 @@ export function getInstructStoppingSequences() {
262 if (sequence.trim().length > 0) {267 if (sequence.trim().length > 0) {
263 const wrappedSequence = wrap(sequence);268 const wrappedSequence = wrap(sequence);
264 // Need to respect "insert macro" setting269 // Need to respect "insert macro" setting
265 const stopString = power_user.instruct.macro ? substituteParams(wrappedSequence) : wrappedSequence;270 const stopString = instruct.macro ? substituteParams(wrappedSequence) : wrappedSequence;
266 result.push(stopString);271 result.push(stopString);
267 }272 }
268 }273 }
@@ -270,14 +275,15 @@ export function getInstructStoppingSequences() {
270275
271 const result = [];276 const result = [];
272277
273 if (power_user.instruct.enabled) {278 // Since preset's don't have "enabled", we assume it's always enabled
274 const stop_sequence = power_user.instruct.stop_sequence || '';279 if (customInstruct ?? instruct.enabled) {
275 const input_sequence = power_user.instruct.input_sequence?.replace(/{{name}}/gi, name1) || '';280 const stop_sequence = instruct.stop_sequence || '';
276 const output_sequence = power_user.instruct.output_sequence?.replace(/{{name}}/gi, name2) || '';281 const input_sequence = instruct.input_sequence?.replace(/{{name}}/gi, name1) || '';
277 const first_output_sequence = power_user.instruct.first_output_sequence?.replace(/{{name}}/gi, name2) || '';282 const output_sequence = instruct.output_sequence?.replace(/{{name}}/gi, name2) || '';
278 const last_output_sequence = power_user.instruct.last_output_sequence?.replace(/{{name}}/gi, name2) || '';283 const first_output_sequence = instruct.first_output_sequence?.replace(/{{name}}/gi, name2) || '';
279 const system_sequence = power_user.instruct.system_sequence?.replace(/{{name}}/gi, 'System') || '';284 const last_output_sequence = instruct.last_output_sequence?.replace(/{{name}}/gi, name2) || '';
280 const last_system_sequence = power_user.instruct.last_system_sequence?.replace(/{{name}}/gi, 'System') || '';285 const system_sequence = instruct.system_sequence?.replace(/{{name}}/gi, 'System') || '';
286 const last_system_sequence = instruct.last_system_sequence?.replace(/{{name}}/gi, 'System') || '';
281287
282 const combined_sequence = [288 const combined_sequence = [
283 stop_sequence,289 stop_sequence,
@@ -292,7 +298,7 @@ export function getInstructStoppingSequences() {
292 combined_sequence.split('\n').filter((line, index, self) => self.indexOf(line) === index).forEach(addInstructSequence);298 combined_sequence.split('\n').filter((line, index, self) => self.indexOf(line) === index).forEach(addInstructSequence);
293 }299 }
294300
295 if (power_user.context.use_stop_strings) {301 if (useStopStrings ?? power_user.context.use_stop_strings) {
296 if (power_user.context.chat_start) {302 if (power_user.context.chat_start) {
297 result.push(`\n${substituteParams(power_user.context.chat_start)}`);303 result.push(`\n${substituteParams(power_user.context.chat_start)}`);
298 }304 }
public/scripts/personas.js+8 -13
@@ -111,6 +111,7 @@ export function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigate
111 reloadUserAvatar();111 reloadUserAvatar();
112 updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });112 updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });
113 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });113 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
114 retriggerFirstMessageOnEmptyChat();
114 saveSettingsDebounced();115 saveSettingsDebounced();
115 $('.zoomed_avatar[forchar]').remove();116 $('.zoomed_avatar[forchar]').remove();
116}117}
@@ -465,7 +466,7 @@ export function initPersona(avatarId, personaName, personaDescription) {
465 * @returns {Promise<boolean>} A promise that resolves to true if the character was converted, false otherwise.466 * @returns {Promise<boolean>} A promise that resolves to true if the character was converted, false otherwise.
466 */467 */
467export async function convertCharacterToPersona(characterId = null) {468export async function convertCharacterToPersona(characterId = null) {
468 if (null === characterId) characterId = this_chid;469 if (null === characterId) characterId = Number(this_chid);
469470
470 const avatarUrl = characters[characterId]?.avatar;471 const avatarUrl = characters[characterId]?.avatar;
471 if (!avatarUrl) {472 if (!avatarUrl) {
@@ -1243,7 +1244,7 @@ function getPersonaStates(avatarId) {
1243 /** @type {PersonaConnection[]} */1244 /** @type {PersonaConnection[]} */
1244 const connections = power_user.persona_descriptions[avatarId]?.connections;1245 const connections = power_user.persona_descriptions[avatarId]?.connections;
1245 const hasCharLock = !!connections?.some(c =>1246 const hasCharLock = !!connections?.some(c =>
1246 (!selected_group && c.type === 'character' && c.id === characters[this_chid]?.avatar)1247 (!selected_group && c.type === 'character' && c.id === characters[Number(this_chid)]?.avatar)
1247 || (selected_group && c.type === 'group' && c.id === selected_group));1248 || (selected_group && c.type === 'group' && c.id === selected_group));
12481249
1249 return {1250 return {
@@ -1481,7 +1482,7 @@ async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1481 * @returns {string[]} - An array of persona keys that are connected to the given character key1482 * @returns {string[]} - An array of persona keys that are connected to the given character key
1482 */1483 */
1483export function getConnectedPersonas(characterKey = undefined) {1484export function getConnectedPersonas(characterKey = undefined) {
1484 characterKey ??= selected_group || characters[this_chid]?.avatar;1485 characterKey ??= selected_group || characters[Number(this_chid)]?.avatar;
1485 const connectedPersonas = Object.entries(power_user.persona_descriptions)1486 const connectedPersonas = Object.entries(power_user.persona_descriptions)
1486 .filter(([_, desc]) => desc.connections?.some(conn => conn.type === 'character' && conn.id === characterKey))1487 .filter(([_, desc]) => desc.connections?.some(conn => conn.type === 'character' && conn.id === characterKey))
1487 .map(([key, _]) => key);1488 .map(([key, _]) => key);
@@ -1513,7 +1514,7 @@ export async function showCharConnections() {
1513 console.log(`Unlocking persona ${personaId} from current character ${name2}`);1514 console.log(`Unlocking persona ${personaId} from current character ${name2}`);
1514 power_user.persona_descriptions[personaId].connections = connections.filter(c => {1515 power_user.persona_descriptions[personaId].connections = connections.filter(c => {
1515 if (menu_type == 'group_edit' && c.type == 'group' && c.id == selected_group) return false;1516 if (menu_type == 'group_edit' && c.type == 'group' && c.id == selected_group) return false;
1516 else if (c.type == 'character' && c.id == characters[this_chid]?.avatar) return false;1517 else if (c.type == 'character' && c.id == characters[Number(this_chid)]?.avatar) return false;
1517 return true;1518 return true;
1518 });1519 });
1519 saveSettingsDebounced();1520 saveSettingsDebounced();
@@ -1545,8 +1546,8 @@ export async function showCharConnections() {
1545export function getCurrentConnectionObj() {1546export function getCurrentConnectionObj() {
1546 if (selected_group)1547 if (selected_group)
1547 return { type: 'group', id: selected_group };1548 return { type: 'group', id: selected_group };
1548 if (characters[this_chid]?.avatar)1549 if (characters[Number(this_chid)]?.avatar)
1549 return { type: 'character', id: characters[this_chid]?.avatar };1550 return { type: 'character', id: characters[Number(this_chid)]?.avatar };
1550 return null;1551 return null;
1551}1552}
15521553
@@ -1664,7 +1665,7 @@ async function syncUserNameToPersona() {
1664 * Only works if only the first message is present, and not in group mode.1665 * Only works if only the first message is present, and not in group mode.
1665 */1666 */
1666export function retriggerFirstMessageOnEmptyChat() {1667export function retriggerFirstMessageOnEmptyChat() {
1667 if (this_chid >= 0 && !selected_group && chat.length === 1) {1668 if (Number(this_chid) >= 0 && !selected_group && chat.length === 1) {
1668 $('#firstmessage_textarea').trigger('input');1669 $('#firstmessage_textarea').trigger('input');
1669 }1670 }
1670}1671}
@@ -1782,7 +1783,6 @@ function setNameCallback({ mode = 'all' }, name) {
1782 if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];1783 if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];
1783 if (persona) {1784 if (persona) {
1784 autoSelectPersona(persona);1785 autoSelectPersona(persona);
1785 retriggerFirstMessageOnEmptyChat();
1786 return '';1786 return '';
1787 } else if (mode === 'lookup') {1787 } else if (mode === 'lookup') {
1788 toastr.warning(`Persona ${name} not found`);1788 toastr.warning(`Persona ${name} not found`);
@@ -1793,7 +1793,6 @@ function setNameCallback({ mode = 'all' }, name) {
1793 if (['temp', 'all'].includes(mode)) {1793 if (['temp', 'all'].includes(mode)) {
1794 // Otherwise, set just the name1794 // Otherwise, set just the name
1795 setUserName(name); //this prevented quickReply usage1795 setUserName(name); //this prevented quickReply usage
1796 retriggerFirstMessageOnEmptyChat();
1797 }1796 }
17981797
1799 return '';1798 return '';
@@ -1944,9 +1943,6 @@ export async function initPersonas() {
1944 $(document).on('click', '#user_avatar_block .avatar-container', function () {1943 $(document).on('click', '#user_avatar_block .avatar-container', function () {
1945 const imgfile = $(this).attr('data-avatar-id');1944 const imgfile = $(this).attr('data-avatar-id');
1946 setUserAvatar(imgfile);1945 setUserAvatar(imgfile);
1947
1948 // force firstMes {{user}} update on persona switch
1949 retriggerFirstMessageOnEmptyChat();
1950 });1946 });
19511947
1952 $('#persona_rename_button').on('click', () => renamePersona(user_avatar));1948 $('#persona_rename_button').on('click', () => renamePersona(user_avatar));
@@ -1979,4 +1975,3 @@ export async function initPersonas() {
1979 eventSource.on(event_types.CHAT_CHANGED, loadPersonaForCurrentChat);1975 eventSource.on(event_types.CHAT_CHANGED, loadPersonaForCurrentChat);
1980 switchPersonaGridView();1976 switchPersonaGridView();
1981}1977}
1982
public/scripts/templates/promptManagerExportForCharacter.html+0 -4
@@ -1,4 +0,0 @@
1<div class="row">
2 <a class="export-promptmanager-prompts-character list-group-item" data-i18n="Export for character">Export for character</a>
3 <span class="tooltip fa-solid fa-info-circle" data-i18n="[title]Export prompts for this character, including their order." title="Export prompts for this character, including their order."></span>
4</div>
public/scripts/templates/promptManagerExportPopup.html+0 -12
@@ -1,12 +0,0 @@
1<div id="prompt-manager-export-format-popup" class="list-group">
2 <div class="prompt-manager-export-format-popup-flex">
3 <div class="row">
4 <a class="export-promptmanager-prompts-full list-group-item" data-i18n="Export all">Export all</a>
5 <span class="tooltip fa-solid fa-info-circle" data-i18n="[title]Export all your prompts to a file" title="Export all your prompts to a file"></span>
6 </div>
7 {{#if isGlobalStrategy}}
8 {{else}}
9 {{{exportForCharacter}}}
10 {{/if}}
11 </div>
12</div>
public/scripts/templates/worldInfoKeywordHeaders.html+1 -1
@@ -1,4 +1,4 @@
1<div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 4.5em;">1<div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 7.0em;">
2 <small class="flex1" data-i18n="Title/Memo">Title/Memo</small>2 <small class="flex1" data-i18n="Title/Memo">Title/Memo</small>
3 <small style="width: calc(3.5em + 10px)" data-i18n="Strategy">Strategy</small>3 <small style="width: calc(3.5em + 10px)" data-i18n="Strategy">Strategy</small>
4 <small style="width: calc(3.5em + 20px)" data-i18n="Position">Position</small>4 <small style="width: calc(3.5em + 20px)" data-i18n="Position">Position</small>
public/scripts/world-info.js+178 -2
@@ -2208,7 +2208,7 @@ function verifyWorldInfoSearchSortRule() {
2208 * Use `originalWIDataKeyMap` to find the correct value to be set.2208 * Use `originalWIDataKeyMap` to find the correct value to be set.
2209 *2209 *
2210 * @param {object} data - The data object containing the original data entries.2210 * @param {object} data - The data object containing the original data entries.
2211 * @param {string} uid - The unique identifier of the data entry.2211 * @param {number} uid - The unique identifier of the data entry.
2212 * @param {string} key - The key of the value to be set.2212 * @param {string} key - The key of the value to be set.
2213 * @param {any} value - The value to be set.2213 * @param {any} value - The value to be set.
2214 */2214 */
@@ -2232,7 +2232,9 @@ export function setWIOriginalDataValue(data, uid, key, value) {
2232 */2232 */
2233export function deleteWIOriginalDataValue(data, uid) {2233export function deleteWIOriginalDataValue(data, uid) {
2234 if (data.originalData && Array.isArray(data.originalData.entries)) {2234 if (data.originalData && Array.isArray(data.originalData.entries)) {
2235 const originalIndex = data.originalData.entries.findIndex(x => x.uid === uid);2235 // Non-strict equality is used here to allow for both string and number comparisons
2236 // @eslint-disable-next-line eqeqeq
2237 const originalIndex = data.originalData.entries.findIndex(x => x.uid == uid);
22362238
2237 if (originalIndex >= 0) {2239 if (originalIndex >= 0) {
2238 data.originalData.entries.splice(originalIndex, 1);2240 data.originalData.entries.splice(originalIndex, 1);
@@ -3143,6 +3145,84 @@ export async function getWorldEntry(name, data, entry) {
3143 updateEditor(navigation_option.previous);3145 updateEditor(navigation_option.previous);
3144 });3146 });
31453147
3148 // move button
3149 const moveButton = template.find('.move_entry_button');
3150 moveButton.attr('data-uid', entry.uid);
3151 moveButton.attr('data-current-world', name);
3152 moveButton.on('click', async function (e) {
3153 e.stopPropagation();
3154 const sourceUid = $(this).attr('data-uid');
3155 const sourceWorld = $(this).attr('data-current-world');
3156 const sourceWorldInfo = await loadWorldInfo(sourceWorld);
3157 if (!sourceWorldInfo) {
3158 return;
3159 }
3160 const sourceName = sourceWorldInfo.entries[sourceUid]?.comment;
3161 if (sourceName === undefined) {
3162 return;
3163 }
3164
3165 const select = document.createElement('select');
3166 select.id = 'move_entry_target_select';
3167 select.classList.add('text_pole', 'wide100p', 'marginTop10');
3168
3169 const defaultOption = document.createElement('option');
3170 defaultOption.value = '';
3171 defaultOption.textContent = `-- ${t`Select Target Lorebook`} --`;
3172 select.appendChild(defaultOption);
3173
3174 let selectableWorldCount = 0;
3175 world_names.forEach(worldName => {
3176 if (worldName !== sourceWorld) { // Exclude current world
3177 const option = document.createElement('option');
3178 option.value = world_names.indexOf(worldName).toString();
3179 option.textContent = worldName;
3180 select.appendChild(option);
3181 selectableWorldCount++;
3182 }
3183 });
3184
3185 if (selectableWorldCount === 0) {
3186 toastr.warning(t`There are no other lorebooks to move to.`);
3187 return;
3188 }
3189
3190 // Create wrapper div
3191 const wrapper = document.createElement('div');
3192 wrapper.textContent = t`Move "${sourceName}" to:`;
3193
3194 // Create container and append elements
3195 const container = document.createElement('div');
3196 container.appendChild(wrapper);
3197 container.appendChild(select);
3198
3199 let selectedWorldIndex = -1;
3200 select.addEventListener('change', function() {
3201 selectedWorldIndex = this.value === '' ? -1 : Number(this.value);
3202 });
3203
3204 const popupConfirm = await callGenericPopup(container, POPUP_TYPE.CONFIRM, '', {
3205 okButton: t`Move`,
3206 cancelButton: t`Cancel`,
3207 });
3208 if (!popupConfirm) {
3209 return;
3210 }
3211
3212 if (selectedWorldIndex === -1) {
3213 return;
3214 }
3215
3216 const selectedValue = world_names[selectedWorldIndex];
3217
3218 if (!selectedValue) {
3219 toastr.warning(t`Please select a target lorebook.`);
3220 return;
3221 }
3222
3223 await moveWorldInfoEntry(sourceWorld, selectedValue, sourceUid);
3224 });
3225
3146 // scan depth3226 // scan depth
3147 const scanDepthInput = template.find('input[name="scanDepth"]');3227 const scanDepthInput = template.find('input[name="scanDepth"]');
3148 scanDepthInput.data('uid', entry.uid);3228 scanDepthInput.data('uid', entry.uid);
@@ -5271,3 +5351,99 @@ jQuery(() => {
5271 });5351 });
5272 });5352 });
5273});5353});
5354
5355/**
5356 * Moves a World Info entry from a source lorebook to a target lorebook.
5357 *
5358 * @param {string} sourceName - The name of the source lorebook file.
5359 * @param {string} targetName - The name of the target lorebook file.
5360 * @param {string|number} uid - The UID of the entry to move from the source lorebook.
5361 * @returns {Promise<boolean>} True if the move was successful, false otherwise.
5362 */
5363export async function moveWorldInfoEntry(sourceName, targetName, uid) {
5364 if (sourceName === targetName) {
5365 return false;
5366 }
5367
5368 if (!world_names.includes(sourceName)) {
5369 toastr.error(t`Source lorebook '${sourceName}' not found.`);
5370 console.error(`[WI Move] Source lorebook '${sourceName}' does not exist.`);
5371 return false;
5372 }
5373
5374 if (!world_names.includes(targetName)) {
5375 toastr.error(t`Target lorebook '${targetName}' not found.`);
5376 console.error(`[WI Move] Target lorebook '${targetName}' does not exist.`);
5377 return false;
5378 }
5379
5380 const entryUidString = String(uid);
5381
5382 try {
5383 const sourceData = await loadWorldInfo(sourceName);
5384 const targetData = await loadWorldInfo(targetName);
5385
5386 if (!sourceData || !sourceData.entries) {
5387 toastr.error(t`Failed to load data for source lorebook '${sourceName}'.`);
5388 console.error(`[WI Move] Could not load source data for '${sourceName}'.`);
5389 return false;
5390 }
5391 if (!targetData || !targetData.entries) {
5392 toastr.error(t`Failed to load data for target lorebook '${targetName}'.`);
5393 console.error(`[WI Move] Could not load target data for '${targetName}'.`);
5394 return false;
5395 }
5396
5397 if (!sourceData.entries[entryUidString]) {
5398 toastr.error(t`Entry not found in source lorebook '${sourceName}'.`);
5399 console.error(`[WI Move] Entry UID ${entryUidString} not found in '${sourceName}'.`);
5400 return false;
5401 }
5402
5403 const entryToMove = structuredClone(sourceData.entries[entryUidString]);
5404
5405
5406 const newUid = getFreeWorldEntryUid(targetData);
5407 if (newUid === null) {
5408 console.error(`[WI Move] Failed to get a free UID in '${targetName}'.`);
5409 return false;
5410 }
5411
5412 entryToMove.uid = newUid;
5413 // Place the entry at the end of the target lorebook
5414 const maxDisplayIndex = Object.values(targetData.entries).reduce((max, entry) => Math.max(max, entry.displayIndex ?? -1), -1);
5415 entryToMove.displayIndex = maxDisplayIndex + 1;
5416
5417 targetData.entries[newUid] = entryToMove;
5418
5419 delete sourceData.entries[entryUidString];
5420 // Remove from originalData if it exists
5421 deleteWIOriginalDataValue(sourceData, entryUidString);
5422 // TODO: setWIOriginalDataValue
5423 console.debug(`[WI Move] Removed entry UID ${entryUidString} from source '${sourceName}'.`);
5424
5425
5426 await saveWorldInfo(targetName, targetData, true);
5427 console.debug(`[WI Move] Saved target lorebook '${targetName}'.`);
5428 await saveWorldInfo(sourceName, sourceData, true);
5429 console.debug(`[WI Move] Saved source lorebook '${sourceName}'.`);
5430
5431
5432 console.log(`[WI Move] ${entryToMove.comment} moved successfully to '${targetName}'.`);
5433
5434 // Check if the currently viewed book in the editor is the source or target and reload it
5435 const currentEditorBookIndex = Number($('#world_editor_select').val());
5436 if (!isNaN(currentEditorBookIndex)) {
5437 const currentEditorBookName = world_names[currentEditorBookIndex];
5438 if (currentEditorBookName === sourceName || currentEditorBookName === targetName) {
5439 reloadEditor(currentEditorBookName);
5440 }
5441 }
5442
5443 return true;
5444 } catch (error) {
5445 toastr.error(t`An unexpected error occurred while moving the entry: ${error.message}`);
5446 console.error('[WI Move] Unexpected error:', error);
5447 return false;
5448 }
5449}