Add Persona CRUD Slash Commands with Shared Avatar Utilities (#5466) * Add persona CRUD slash commands with enhanced utilities - Add `/persona-create`, `/persona-update`, `/persona-delete`, `/persona-duplicate`, and `/persona-get` slash commands for programmatic persona management - Move `persona_description_positions` enum from power-user.js to personas.js for better encapsulation - Add position and role parsing utilities (`parsePersonaPosition`, `parsePersonaRole`) with name-to-value mapping - Extend `initPersona()` to accept optional position, depth, role, and lorebook parameters - Refactor `deleteUserAvatar()` to delegate to new `deletePersona * Add NaN validation for descriptionDepth parameter in createPersonaCallback - Add isNaN() check for depth parameter after Number() conversion - Display warning toast when invalid depth value is provided - Fall back to DEFAULT_DEPTH when depth is NaN - Change depth from const to let to allow reassignment after validation * Refactor persona lookup to support avatar key targeting and fix enum provider currying - Refactor `autoSelectPersona()` to accept optional `personaKey` parameter for targeting specific persona when multiple share the same name - Replace manual persona lookup loops with `findPersona()` helper calls in `autoSelectPersona()` and `setNameCallback()` - Update `setNameCallback()` to pass both persona name and avatar key to `autoSelectPersona()` for precise targeting - Refactor `commonEnumProviders.personas()`
Signed| @@ -22,7 +22,7 @@ import { | |||
| 22 | setUserName, | 22 | setUserName, |
| 23 | this_chid, | 23 | this_chid, |
| 24 | } from '../script.js'; | 24 | } from '../script.js'; |
| 25 | import { persona_description_positions, power_user } from './power-user.js'; | 25 | import { power_user } from './power-user.js'; |
| 26 | import { getTokenCountAsync } from './tokenizers.js'; | 26 | import { getTokenCountAsync } from './tokenizers.js'; |
| 27 | import { | 27 | import { |
| 28 | PAGINATION_TEMPLATE, | 28 | PAGINATION_TEMPLATE, |
| @@ -47,6 +47,8 @@ import { | |||
| 47 | sortIgnoreCaseAndAccents, | 47 | sortIgnoreCaseAndAccents, |
| 48 | equalsIgnoreCaseAndAccents, | 48 | equalsIgnoreCaseAndAccents, |
| 49 | uuidv4, | 49 | uuidv4, |
| 50 | resolveAvatarData, | ||
| 51 | findPersona, | ||
| 50 | } from './utils.js'; | 52 | } from './utils.js'; |
| 51 | import { debounce_timeout } from './constants.js'; | 53 | import { debounce_timeout } from './constants.js'; |
| 52 | import { FILTER_TYPES, FilterHelper } from './filters.js'; | 54 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| @@ -59,10 +61,11 @@ import { saveMetadataDebounced } from './extensions.js'; | |||
| 59 | import { accountStorage } from './util/AccountStorage.js'; | 61 | import { accountStorage } from './util/AccountStorage.js'; |
| 60 | import { SlashCommand } from './slash-commands/SlashCommand.js'; | 62 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 61 | import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js'; | 63 | import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js'; |
| 62 | import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; | 64 | import { commonEnumMatchProviders, commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 63 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; | 65 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 64 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | 66 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 65 | import { isFirefox } from './browser-fixes.js'; | 67 | import { isFirefox } from './browser-fixes.js'; |
| 68 | import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js'; | ||
| 66 | 69 | ||
| 67 | /** | 70 | /** |
| 68 | * @typedef {object} PersonaConnection A connection between a character and a character or group entity | 71 | * @typedef {object} PersonaConnection A connection between a character and a character or group entity |
| @@ -81,6 +84,18 @@ import { isFirefox } from './browser-fixes.js'; | |||
| 81 | * @property {boolean} locked.character - Whether the persona is locked to the currently open character or group | 84 | * @property {boolean} locked.character - Whether the persona is locked to the currently open character or group |
| 82 | */ | 85 | */ |
| 83 | 86 | ||
| 87 | export const persona_description_positions = { | ||
| 88 | IN_PROMPT: 0, | ||
| 89 | /** | ||
| 90 | * @deprecated Use persona_description_positions.IN_PROMPT instead. | ||
| 91 | */ | ||
| 92 | AFTER_CHAR: 1, | ||
| 93 | TOP_AN: 2, | ||
| 94 | BOTTOM_AN: 3, | ||
| 95 | AT_DEPTH: 4, | ||
| 96 | NONE: 9, | ||
| 97 | }; | ||
| 98 | |||
| 84 | const USER_AVATAR_PATH = 'User Avatars/'; | 99 | const USER_AVATAR_PATH = 'User Avatars/'; |
| 85 | 100 | ||
| 86 | let savePersonasPage = 0; | 101 | let savePersonasPage = 0; |
| @@ -488,18 +503,28 @@ async function createDummyPersona() { | |||
| 488 | * @param {string} personaName Name for the persona | 503 | * @param {string} personaName Name for the persona |
| 489 | * @param {string} personaDescription Optional description for the persona | 504 | * @param {string} personaDescription Optional description for the persona |
| 490 | * @param {string} personaTitle Optional title for the persona | 505 | * @param {string} personaTitle Optional title for the persona |
| 491 | * @param {object} [options] Optional settings | 506 | * @param {object} [options={}] Optional settings |
| 492 | * @param {boolean} [options.silent=false] If true, no PERSONA_CREATED event is emitted (used for background migrations) | 507 | * @param {boolean} [options.silent=false] If true, no PERSONA_CREATED event is emitted (used for background migrations) |
| 508 | * @param {number} [options.position=persona_description_positions.IN_PROMPT] Description position (defaults to IN_PROMPT) | ||
| 509 | * @param {number} [options.depth=DEFAULT_DEPTH] Description depth (defaults to DEFAULT_DEPTH) | ||
| 510 | * @param {number} [options.role=DEFAULT_ROLE] Description role (defaults to DEFAULT_ROLE) | ||
| 511 | * @param {string} [options.lorebook=''] Attached lorebook name | ||
| 493 | * @returns {Promise<void>} | 512 | * @returns {Promise<void>} |
| 494 | */ | 513 | */ |
| 495 | export async function initPersona(avatarId, personaName, personaDescription, personaTitle, { silent = false } = {}) { | 514 | export async function initPersona(avatarId, personaName, personaDescription, personaTitle, { |
| 515 | silent = false, | ||
| 516 | position = persona_description_positions.IN_PROMPT, | ||
| 517 | depth = DEFAULT_DEPTH, | ||
| 518 | role = DEFAULT_ROLE, | ||
| 519 | lorebook = '', | ||
| 520 | } = {}) { | ||
| 496 | power_user.personas[avatarId] = personaName; | 521 | power_user.personas[avatarId] = personaName; |
| 497 | power_user.persona_descriptions[avatarId] = { | 522 | power_user.persona_descriptions[avatarId] = { |
| 498 | description: personaDescription || '', | 523 | description: personaDescription || '', |
| 499 | position: persona_description_positions.IN_PROMPT, | 524 | position: position, |
| 500 | depth: DEFAULT_DEPTH, | 525 | depth: depth, |
| 501 | role: DEFAULT_ROLE, | 526 | role: role, |
| 502 | lorebook: '', | 527 | lorebook: lorebook, |
| 503 | title: personaTitle || '', | 528 | title: personaTitle || '', |
| 504 | }; | 529 | }; |
| 505 | 530 | ||
| @@ -773,16 +798,17 @@ export async function askForPersonaSelection(title, text, personas, { okButton = | |||
| 773 | /** | 798 | /** |
| 774 | * Automatically selects a persona based on the given name if a matching persona exists. | 799 | * Automatically selects a persona based on the given name if a matching persona exists. |
| 775 | * @param {string} name - The name to search for | 800 | * @param {string} name - The name to search for |
| 801 | * @param {Object} [options={}] | ||
| 802 | * @param {string} [options.personaKey=null] - Optionally a persona avatar key to target (if multiple persona have the same name); must match the name | ||
| 776 | * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise | 803 | * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise |
| 777 | */ | 804 | */ |
| 778 | export async function autoSelectPersona(name) { | 805 | export async function autoSelectPersona(name, { personaKey = null } = {}) { |
| 779 | for (const [key, value] of Object.entries(power_user.personas)) { | 806 | const persona = findPersona({ name: personaKey ?? name, allowAvatar: !!personaKey }); |
| 780 | if (value === name) { | 807 | if (persona) { |
| 781 | console.log(`Auto-selecting persona ${key} for name ${name}`); | 808 | console.log(`Auto-selecting persona ${persona.avatar} for name ${name}`); |
| 782 | await setUserAvatar(key); | 809 | await setUserAvatar(persona.avatar); |
| 783 | return true; | 810 | return true; |
| 784 | } | 811 | } |
| 785 | } | ||
| 786 | return false; | 812 | return false; |
| 787 | } | 813 | } |
| 788 | 814 | ||
| @@ -1106,21 +1132,37 @@ async function lockPersona(type = 'chat') { | |||
| 1106 | } | 1132 | } |
| 1107 | 1133 | ||
| 1108 | 1134 | ||
| 1135 | /** | ||
| 1136 | * Click handler for the delete persona button. Delegates to deletePersona with the current user avatar. | ||
| 1137 | */ | ||
| 1109 | async function deleteUserAvatar() { | 1138 | async function deleteUserAvatar() { |
| 1110 | const avatarId = user_avatar; | 1139 | await deletePersona(user_avatar); |
| 1140 | } | ||
| 1111 | 1141 | ||
| 1142 | /** | ||
| 1143 | * Deletes a persona by avatar id. | ||
| 1144 | * @param {string} avatarId The persona's avatar id to delete | ||
| 1145 | * @param {object} [options] Options | ||
| 1146 | * @param {boolean} [options.silent=false] If true, skips the confirmation popup and suppresses toast notifications | ||
| 1147 | * @returns {Promise<boolean>} True if the persona was deleted | ||
| 1148 | */ | ||
| 1149 | async function deletePersona(avatarId, { silent = false } = {}) { | ||
| 1112 | if (!avatarId) { | 1150 | if (!avatarId) { |
| 1113 | console.warn('No avatar id found'); | 1151 | console.warn('No avatar id found'); |
| 1114 | return; | 1152 | return false; |
| 1115 | } | 1153 | } |
| 1154 | |||
| 1116 | const name = power_user.personas[avatarId] || ''; | 1155 | const name = power_user.personas[avatarId] || ''; |
| 1156 | |||
| 1157 | if (!silent) { | ||
| 1117 | const confirm = await Popup.show.confirm( | 1158 | const confirm = await Popup.show.confirm( |
| 1118 | t`Delete Persona` + `: ${name}`, | 1159 | t`Delete Persona` + `: ${name}`, |
| 1119 | t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`); | 1160 | t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`); |
| 1120 | 1161 | ||
| 1121 | if (!confirm) { | 1162 | if (!confirm) { |
| 1122 | console.debug('User cancelled deleting avatar'); | 1163 | console.debug('User cancelled deleting avatar'); |
| 1123 | return; | 1164 | return false; |
| 1165 | } | ||
| 1124 | } | 1166 | } |
| 1125 | 1167 | ||
| 1126 | const request = await fetch('/api/avatars/delete', { | 1168 | const request = await fetch('/api/avatars/delete', { |
| @@ -1137,12 +1179,12 @@ async function deleteUserAvatar() { | |||
| 1137 | delete power_user.persona_descriptions[avatarId]; | 1179 | delete power_user.persona_descriptions[avatarId]; |
| 1138 | 1180 | ||
| 1139 | if (avatarId === power_user.default_persona) { | 1181 | if (avatarId === power_user.default_persona) { |
| 1140 | toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`); | 1182 | if (!silent) toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`); |
| 1141 | power_user.default_persona = null; | 1183 | power_user.default_persona = null; |
| 1142 | } | 1184 | } |
| 1143 | 1185 | ||
| 1144 | if (avatarId === chat_metadata.persona) { | 1186 | if (avatarId === chat_metadata.persona) { |
| 1145 | toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`); | 1187 | if (!silent) toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`); |
| 1146 | delete chat_metadata.persona; | 1188 | delete chat_metadata.persona; |
| 1147 | await saveMetadata(); | 1189 | await saveMetadata(); |
| 1148 | } | 1190 | } |
| @@ -1153,7 +1195,10 @@ async function deleteUserAvatar() { | |||
| 1153 | // Use the existing mechanism to re-render the persona list and choose the next persona here | 1195 | // Use the existing mechanism to re-render the persona list and choose the next persona here |
| 1154 | personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id | 1196 | personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id |
| 1155 | await loadPersonaForCurrentChat({ doRender: true }); | 1197 | await loadPersonaForCurrentChat({ doRender: true }); |
| 1198 | return true; | ||
| 1156 | } | 1199 | } |
| 1200 | |||
| 1201 | return false; | ||
| 1157 | } | 1202 | } |
| 1158 | 1203 | ||
| 1159 | async function onPersonaDescriptionInput() { | 1204 | async function onPersonaDescriptionInput() { |
| @@ -1839,22 +1884,27 @@ export async function retriggerFirstMessageOnEmptyChat() { | |||
| 1839 | 1884 | ||
| 1840 | /** | 1885 | /** |
| 1841 | * Duplicates a persona. | 1886 | * Duplicates a persona. |
| 1842 | * @param {string} avatarId | 1887 | * @param {string} avatarId Source persona avatar id |
| 1843 | * @returns {Promise<void>} | 1888 | * @param {object} [options] Options |
| 1889 | * @param {boolean} [options.silent=false] If true, skips the confirmation popup | ||
| 1890 | * @param {boolean} [options.select=false] If true, selects/activates the duplicated persona | ||
| 1891 | * @returns {Promise<string>} The avatar id of the new persona, or empty string on failure/cancellation | ||
| 1844 | */ | 1892 | */ |
| 1845 | async function duplicatePersona(avatarId) { | 1893 | async function duplicatePersona(avatarId, { silent = false, select = false } = {}) { |
| 1846 | const personaName = power_user.personas[avatarId]; | 1894 | const personaName = power_user.personas[avatarId]; |
| 1847 | 1895 | ||
| 1848 | if (!personaName) { | 1896 | if (!personaName) { |
| 1849 | toastr.warning('Chosen avatar is not a persona', t`Persona Management`); | 1897 | toastr.warning(t`Chosen avatar is not a persona`, t`Persona Management`); |
| 1850 | return; | 1898 | return ''; |
| 1851 | } | 1899 | } |
| 1852 | 1900 | ||
| 1901 | if (!silent) { | ||
| 1853 | const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName); | 1902 | const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName); |
| 1854 | 1903 | ||
| 1855 | if (!confirm) { | 1904 | if (!confirm) { |
| 1856 | console.debug('User cancelled duplicating persona'); | 1905 | console.debug('User cancelled duplicating persona'); |
| 1857 | return; | 1906 | return ''; |
| 1907 | } | ||
| 1858 | } | 1908 | } |
| 1859 | 1909 | ||
| 1860 | const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`; | 1910 | const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`; |
| @@ -1883,6 +1933,12 @@ async function duplicatePersona(avatarId) { | |||
| 1883 | 1933 | ||
| 1884 | await getUserAvatars(true, newAvatarId); | 1934 | await getUserAvatars(true, newAvatarId); |
| 1885 | saveSettingsDebounced(); | 1935 | saveSettingsDebounced(); |
| 1936 | |||
| 1937 | if (select) { | ||
| 1938 | await setUserAvatar(newAvatarId); | ||
| 1939 | } | ||
| 1940 | |||
| 1941 | return newAvatarId; | ||
| 1886 | } | 1942 | } |
| 1887 | 1943 | ||
| 1888 | /** | 1944 | /** |
| @@ -1899,6 +1955,412 @@ async function migrateNonPersonaUser() { | |||
| 1899 | } | 1955 | } |
| 1900 | 1956 | ||
| 1901 | 1957 | ||
| 1958 | // #region Persona CRUD Slash Command Utilities | ||
| 1959 | |||
| 1960 | /** | ||
| 1961 | * Mapping of human-readable position names to persona_description_positions enum values. | ||
| 1962 | * @type {Record<string, number>} | ||
| 1963 | */ | ||
| 1964 | const POSITION_NAME_MAP = Object.freeze({ | ||
| 1965 | 'inprompt': persona_description_positions.IN_PROMPT, | ||
| 1966 | 'topan': persona_description_positions.TOP_AN, | ||
| 1967 | 'bottoman': persona_description_positions.BOTTOM_AN, | ||
| 1968 | 'atdepth': persona_description_positions.AT_DEPTH, | ||
| 1969 | 'none': persona_description_positions.NONE, | ||
| 1970 | }); | ||
| 1971 | |||
| 1972 | /** | ||
| 1973 | * Mapping of human-readable role names to numeric role values. | ||
| 1974 | * @type {Record<string, number>} | ||
| 1975 | */ | ||
| 1976 | const ROLE_NAME_MAP = Object.freeze({ | ||
| 1977 | 'system': 0, | ||
| 1978 | 'user': 1, | ||
| 1979 | 'assistant': 2, | ||
| 1980 | }); | ||
| 1981 | |||
| 1982 | /** | ||
| 1983 | * Parses a persona description position from a string or number value. | ||
| 1984 | * @param {string|number|undefined} value Position value (name or number) | ||
| 1985 | * @returns {number|null} Parsed position value, or null if invalid/undefined | ||
| 1986 | */ | ||
| 1987 | function parsePersonaPosition(value) { | ||
| 1988 | if (value === undefined || value === null) return null; | ||
| 1989 | const strValue = String(value).toLowerCase(); | ||
| 1990 | if (strValue in POSITION_NAME_MAP) return POSITION_NAME_MAP[strValue]; | ||
| 1991 | const numValue = Number(value); | ||
| 1992 | if (!isNaN(numValue) && Object.values(persona_description_positions).includes(numValue)) return numValue; | ||
| 1993 | return null; | ||
| 1994 | } | ||
| 1995 | |||
| 1996 | /** | ||
| 1997 | * Parses a persona description role from a string or number value. | ||
| 1998 | * @param {string|number|undefined} value Role value (name or number) | ||
| 1999 | * @returns {number|null} Parsed role value, or null if invalid/undefined | ||
| 2000 | */ | ||
| 2001 | function parsePersonaRole(value) { | ||
| 2002 | if (value === undefined || value === null) return null; | ||
| 2003 | const strValue = String(value).toLowerCase(); | ||
| 2004 | if (strValue in ROLE_NAME_MAP) return ROLE_NAME_MAP[strValue]; | ||
| 2005 | const numValue = Number(value); | ||
| 2006 | if (!isNaN(numValue) && numValue >= 0 && numValue <= 2) return numValue; | ||
| 2007 | return null; | ||
| 2008 | } | ||
| 2009 | |||
| 2010 | /** | ||
| 2011 | * Uploads base64 avatar data to a persona, optionally showing a crop dialog. | ||
| 2012 | * @param {string} avatarId The persona's avatar file name | ||
| 2013 | * @param {string} base64Data Base64 data URL of the image | ||
| 2014 | * @param {object} [options] Options | ||
| 2015 | * @param {boolean} [options.resizePrompt=false] Whether to show the crop dialog | ||
| 2016 | * @returns {Promise<boolean>} True if upload was successful | ||
| 2017 | */ | ||
| 2018 | async function uploadPersonaAvatar(avatarId, base64Data, { resizePrompt = false } = {}) { | ||
| 2019 | if (!base64Data || !avatarId) return false; | ||
| 2020 | |||
| 2021 | let finalImageData = base64Data; | ||
| 2022 | |||
| 2023 | if (resizePrompt && !power_user.never_resize_avatars) { | ||
| 2024 | const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: base64Data }); | ||
| 2025 | const croppedImage = await dlg.show(); | ||
| 2026 | if (!croppedImage) return false; | ||
| 2027 | finalImageData = String(croppedImage); | ||
| 2028 | } | ||
| 2029 | |||
| 2030 | try { | ||
| 2031 | const response = await fetch(finalImageData); | ||
| 2032 | const blob = await response.blob(); | ||
| 2033 | const file = new File([blob], 'avatar.png', { type: 'image/png' }); | ||
| 2034 | const formData = new FormData(); | ||
| 2035 | formData.append('avatar', file); | ||
| 2036 | formData.append('overwrite_name', avatarId); | ||
| 2037 | |||
| 2038 | const uploadResponse = await fetch('/api/avatars/upload', { | ||
| 2039 | method: 'POST', | ||
| 2040 | headers: getRequestHeaders({ omitContentType: true }), | ||
| 2041 | cache: 'no-cache', | ||
| 2042 | body: formData, | ||
| 2043 | }); | ||
| 2044 | |||
| 2045 | if (!uploadResponse.ok) { | ||
| 2046 | throw new Error(`Upload failed: ${uploadResponse.statusText}`); | ||
| 2047 | } | ||
| 2048 | |||
| 2049 | // Cache bust for the updated avatar | ||
| 2050 | await fetch(getUserAvatar(avatarId), { cache: 'reload' }); | ||
| 2051 | await fetch(getThumbnailUrl('persona', avatarId), { cache: 'reload' }); | ||
| 2052 | reloadUserAvatar(true); | ||
| 2053 | return true; | ||
| 2054 | } catch (error) { | ||
| 2055 | console.error('Error uploading persona avatar:', error); | ||
| 2056 | toastr.warning(t`Failed to upload avatar: ${error.message}`); | ||
| 2057 | return false; | ||
| 2058 | } | ||
| 2059 | } | ||
| 2060 | |||
| 2061 | /** | ||
| 2062 | * Resolves a persona from the given argument or falls back to the currently active persona. | ||
| 2063 | * @param {string} [personaArg] Persona name or avatar key argument | ||
| 2064 | * @returns {import('./utils.js').PersonaViewModel|null} The resolved persona, or null if not found | ||
| 2065 | */ | ||
| 2066 | function getTargetPersona(personaArg) { | ||
| 2067 | if (personaArg) { | ||
| 2068 | const persona = findPersona({ name: personaArg }); | ||
| 2069 | if (!persona) { | ||
| 2070 | toastr.warning(t`Persona "${personaArg}" not found`); | ||
| 2071 | return null; | ||
| 2072 | } | ||
| 2073 | return persona; | ||
| 2074 | } | ||
| 2075 | |||
| 2076 | // Fall back to currently active persona | ||
| 2077 | const persona = findPersona({ preferCurrentPersona: true }); | ||
| 2078 | if (!persona) { | ||
| 2079 | toastr.warning(t`No persona selected and no persona argument provided`); | ||
| 2080 | return null; | ||
| 2081 | } | ||
| 2082 | return persona; | ||
| 2083 | } | ||
| 2084 | |||
| 2085 | // #endregion | ||
| 2086 | |||
| 2087 | // #region Persona CRUD Slash Command Callbacks | ||
| 2088 | |||
| 2089 | /** | ||
| 2090 | * Creates a new persona with the specified attributes. | ||
| 2091 | * @param {object} args Named arguments from the slash command | ||
| 2092 | * @returns {Promise<string>} Avatar key of the created persona, or empty string on failure | ||
| 2093 | */ | ||
| 2094 | async function createPersonaCallback(args) { | ||
| 2095 | const name = args.name; | ||
| 2096 | if (!name || typeof name !== 'string' || !name.trim()) { | ||
| 2097 | toastr.warning(t`Persona name is required`); | ||
| 2098 | return ''; | ||
| 2099 | } | ||
| 2100 | |||
| 2101 | const trimmedName = name.trim(); | ||
| 2102 | const avatarId = `${Date.now()}-${trimmedName.replace(/[^a-zA-Z0-9]/g, '')}.png`; | ||
| 2103 | |||
| 2104 | const description = args.description ?? ''; | ||
| 2105 | const title = args.title ?? ''; | ||
| 2106 | const position = parsePersonaPosition(args.descriptionPosition) ?? persona_description_positions.IN_PROMPT; | ||
| 2107 | const role = parsePersonaRole(args.descriptionRole) ?? DEFAULT_ROLE; | ||
| 2108 | const lorebook = args.lorebook ?? ''; | ||
| 2109 | |||
| 2110 | let depth = args.descriptionDepth !== undefined ? Number(args.descriptionDepth) : DEFAULT_DEPTH; | ||
| 2111 | if (isNaN(depth)) { | ||
| 2112 | toastr.warning(t`Invalid description depth "${args.descriptionDepth}", defaulting to ${DEFAULT_DEPTH}`); | ||
| 2113 | depth = DEFAULT_DEPTH; | ||
| 2114 | } | ||
| 2115 | |||
| 2116 | // Initialize persona data with all fields | ||
| 2117 | await initPersona(avatarId, trimmedName, description, title, { | ||
| 2118 | position, depth, role, lorebook, | ||
| 2119 | }); | ||
| 2120 | |||
| 2121 | // Handle avatar upload | ||
| 2122 | const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null; | ||
| 2123 | if (avatarData) { | ||
| 2124 | const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true'); | ||
| 2125 | const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt }); | ||
| 2126 | if (!uploaded) { | ||
| 2127 | // Crop was cancelled or upload failed — use default avatar | ||
| 2128 | await uploadUserAvatar(default_user_avatar, avatarId); | ||
| 2129 | } | ||
| 2130 | } else { | ||
| 2131 | await uploadUserAvatar(default_user_avatar, avatarId); | ||
| 2132 | } | ||
| 2133 | |||
| 2134 | saveSettingsDebounced(); | ||
| 2135 | await getUserAvatars(true, avatarId); | ||
| 2136 | |||
| 2137 | // Select/activate if requested (default: true) | ||
| 2138 | if (!isFalseBoolean(args.select ?? 'true')) { | ||
| 2139 | await setUserAvatar(avatarId); | ||
| 2140 | } | ||
| 2141 | |||
| 2142 | toastr.success(t`Persona "${trimmedName}" created successfully`); | ||
| 2143 | return avatarId; | ||
| 2144 | } | ||
| 2145 | |||
| 2146 | /** | ||
| 2147 | * Updates an existing persona's attributes. | ||
| 2148 | * @param {object} args Named arguments from the slash command | ||
| 2149 | * @returns {Promise<string>} Avatar key of the updated persona, or empty string on failure | ||
| 2150 | */ | ||
| 2151 | async function updatePersonaCallback(args) { | ||
| 2152 | const persona = getTargetPersona(args.persona); | ||
| 2153 | if (!persona) return ''; | ||
| 2154 | |||
| 2155 | const avatarId = persona.avatar; | ||
| 2156 | const descriptor = power_user.persona_descriptions[avatarId]; | ||
| 2157 | |||
| 2158 | if (!descriptor) { | ||
| 2159 | toastr.warning(t`Persona data not found for "${persona.name}"`); | ||
| 2160 | return ''; | ||
| 2161 | } | ||
| 2162 | |||
| 2163 | let hasUpdates = false; | ||
| 2164 | |||
| 2165 | // Update name | ||
| 2166 | if (args.name !== undefined) { | ||
| 2167 | const newName = String(args.name).trim(); | ||
| 2168 | if (newName) { | ||
| 2169 | const oldName = power_user.personas[avatarId]; | ||
| 2170 | power_user.personas[avatarId] = newName; | ||
| 2171 | if (avatarId === user_avatar) { | ||
| 2172 | setUserName(newName); | ||
| 2173 | } | ||
| 2174 | await eventSource.emit(event_types.PERSONA_RENAMED, { avatarId, oldName, newName }); | ||
| 2175 | hasUpdates = true; | ||
| 2176 | } | ||
| 2177 | } | ||
| 2178 | |||
| 2179 | // Update description | ||
| 2180 | if (args.description !== undefined) { | ||
| 2181 | descriptor.description = args.description; | ||
| 2182 | if (avatarId === user_avatar) { | ||
| 2183 | power_user.persona_description = args.description; | ||
| 2184 | } | ||
| 2185 | hasUpdates = true; | ||
| 2186 | } | ||
| 2187 | |||
| 2188 | // Update title | ||
| 2189 | if (args.title !== undefined) { | ||
| 2190 | descriptor.title = args.title; | ||
| 2191 | hasUpdates = true; | ||
| 2192 | } | ||
| 2193 | |||
| 2194 | // Update description position | ||
| 2195 | if (args.descriptionPosition !== undefined) { | ||
| 2196 | const position = parsePersonaPosition(args.descriptionPosition); | ||
| 2197 | if (position !== null) { | ||
| 2198 | descriptor.position = position; | ||
| 2199 | if (avatarId === user_avatar) { | ||
| 2200 | power_user.persona_description_position = position; | ||
| 2201 | } | ||
| 2202 | hasUpdates = true; | ||
| 2203 | } | ||
| 2204 | } | ||
| 2205 | |||
| 2206 | // Update description depth | ||
| 2207 | if (args.descriptionDepth !== undefined) { | ||
| 2208 | const depth = Number(args.descriptionDepth); | ||
| 2209 | if (!isNaN(depth)) { | ||
| 2210 | descriptor.depth = depth; | ||
| 2211 | if (avatarId === user_avatar) { | ||
| 2212 | power_user.persona_description_depth = depth; | ||
| 2213 | } | ||
| 2214 | hasUpdates = true; | ||
| 2215 | } | ||
| 2216 | } | ||
| 2217 | |||
| 2218 | // Update description role | ||
| 2219 | if (args.descriptionRole !== undefined) { | ||
| 2220 | const role = parsePersonaRole(args.descriptionRole); | ||
| 2221 | if (role !== null) { | ||
| 2222 | descriptor.role = role; | ||
| 2223 | if (avatarId === user_avatar) { | ||
| 2224 | power_user.persona_description_role = role; | ||
| 2225 | } | ||
| 2226 | hasUpdates = true; | ||
| 2227 | } | ||
| 2228 | } | ||
| 2229 | |||
| 2230 | // Update lorebook | ||
| 2231 | if (args.lorebook !== undefined) { | ||
| 2232 | descriptor.lorebook = args.lorebook; | ||
| 2233 | if (avatarId === user_avatar) { | ||
| 2234 | power_user.persona_description_lorebook = args.lorebook; | ||
| 2235 | } | ||
| 2236 | hasUpdates = true; | ||
| 2237 | } | ||
| 2238 | |||
| 2239 | // Handle avatar | ||
| 2240 | const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null; | ||
| 2241 | if (avatarData) { | ||
| 2242 | const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true'); | ||
| 2243 | const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt }); | ||
| 2244 | if (uploaded) { | ||
| 2245 | hasUpdates = true; | ||
| 2246 | } | ||
| 2247 | } | ||
| 2248 | |||
| 2249 | if (!hasUpdates) { | ||
| 2250 | toastr.info(t`No fields provided to update`); | ||
| 2251 | return avatarId; | ||
| 2252 | } | ||
| 2253 | |||
| 2254 | saveSettingsDebounced(); | ||
| 2255 | await eventSource.emit(event_types.PERSONA_UPDATED, avatarId); | ||
| 2256 | |||
| 2257 | // Refresh UI if the updated persona is the active one | ||
| 2258 | if (avatarId === user_avatar) { | ||
| 2259 | setPersonaDescription(); | ||
| 2260 | } | ||
| 2261 | await getUserAvatars(true, avatarId); | ||
| 2262 | updatePersonaUIStates(); | ||
| 2263 | |||
| 2264 | toastr.success(t`Persona "${power_user.personas[avatarId]}" updated successfully`); | ||
| 2265 | return avatarId; | ||
| 2266 | } | ||
| 2267 | |||
| 2268 | /** | ||
| 2269 | * Retrieves persona data or a specific field. | ||
| 2270 | * @param {object} args Named arguments from the slash command | ||
| 2271 | * @returns {Promise<string>} The persona data or field value | ||
| 2272 | */ | ||
| 2273 | async function getPersonaDataCallback(args) { | ||
| 2274 | const persona = getTargetPersona(args.persona); | ||
| 2275 | if (!persona) return ''; | ||
| 2276 | |||
| 2277 | const avatarId = persona.avatar; | ||
| 2278 | const descriptor = power_user.persona_descriptions[avatarId] ?? {}; | ||
| 2279 | |||
| 2280 | if (args.field) { | ||
| 2281 | /** @type {Record<string, unknown>} */ | ||
| 2282 | const fieldMap = { | ||
| 2283 | name: power_user.personas[avatarId] ?? '', | ||
| 2284 | description: descriptor.description ?? '', | ||
| 2285 | title: descriptor.title ?? '', | ||
| 2286 | position: descriptor.position ?? persona_description_positions.IN_PROMPT, | ||
| 2287 | depth: descriptor.depth ?? DEFAULT_DEPTH, | ||
| 2288 | role: descriptor.role ?? DEFAULT_ROLE, | ||
| 2289 | lorebook: descriptor.lorebook ?? '', | ||
| 2290 | avatar: avatarId, | ||
| 2291 | default: power_user.default_persona === avatarId, | ||
| 2292 | connections: descriptor.connections ?? [], | ||
| 2293 | }; | ||
| 2294 | |||
| 2295 | const value = fieldMap[args.field]; | ||
| 2296 | if (value === undefined) { | ||
| 2297 | toastr.warning(t`Unknown persona field "${args.field}"`); | ||
| 2298 | return ''; | ||
| 2299 | } | ||
| 2300 | |||
| 2301 | return await slashCommandReturnHelper.doReturn( | ||
| 2302 | args.return ?? 'pipe', value, | ||
| 2303 | { objectToStringFunc: x => typeof x === 'object' ? JSON.stringify(x) : String(x) }, | ||
| 2304 | ); | ||
| 2305 | } | ||
| 2306 | |||
| 2307 | // Return full persona data | ||
| 2308 | const personaData = { | ||
| 2309 | avatar: avatarId, | ||
| 2310 | name: power_user.personas[avatarId] ?? '', | ||
| 2311 | description: descriptor.description ?? '', | ||
| 2312 | title: descriptor.title ?? '', | ||
| 2313 | position: descriptor.position ?? persona_description_positions.IN_PROMPT, | ||
| 2314 | depth: descriptor.depth ?? DEFAULT_DEPTH, | ||
| 2315 | role: descriptor.role ?? DEFAULT_ROLE, | ||
| 2316 | lorebook: descriptor.lorebook ?? '', | ||
| 2317 | default: power_user.default_persona === avatarId, | ||
| 2318 | connections: descriptor.connections ?? [], | ||
| 2319 | }; | ||
| 2320 | |||
| 2321 | return await slashCommandReturnHelper.doReturn( | ||
| 2322 | args.return ?? 'pipe', personaData, | ||
| 2323 | { objectToStringFunc: x => JSON.stringify(x, null, 2) }, | ||
| 2324 | ); | ||
| 2325 | } | ||
| 2326 | |||
| 2327 | /** | ||
| 2328 | * Deletes a persona via slash command. | ||
| 2329 | * @param {object} args Named arguments from the slash command | ||
| 2330 | * @returns {Promise<string>} 'true' if deleted, 'false' otherwise | ||
| 2331 | */ | ||
| 2332 | async function deletePersonaCallback(args) { | ||
| 2333 | const persona = getTargetPersona(args.persona); | ||
| 2334 | if (!persona) return 'false'; | ||
| 2335 | |||
| 2336 | const silent = isTrueBoolean(args.silent); | ||
| 2337 | const success = await deletePersona(persona.avatar, { silent }); | ||
| 2338 | return String(success); | ||
| 2339 | } | ||
| 2340 | |||
| 2341 | /** | ||
| 2342 | * Duplicates a persona via slash command. | ||
| 2343 | * @param {object} args Named arguments from the slash command | ||
| 2344 | * @returns {Promise<string>} Avatar key of the duplicated persona, or empty string on failure | ||
| 2345 | */ | ||
| 2346 | async function duplicatePersonaCallback(args) { | ||
| 2347 | const persona = getTargetPersona(args.persona); | ||
| 2348 | if (!persona) return ''; | ||
| 2349 | |||
| 2350 | const shouldSelect = isTrueBoolean(args.select); | ||
| 2351 | const newAvatarId = await duplicatePersona(persona.avatar, { silent: true, select: shouldSelect }); | ||
| 2352 | |||
| 2353 | if (!newAvatarId) { | ||
| 2354 | toastr.error(t`Failed to duplicate persona`); | ||
| 2355 | return ''; | ||
| 2356 | } | ||
| 2357 | |||
| 2358 | toastr.success(t`Persona "${power_user.personas[newAvatarId]}" duplicated successfully`); | ||
| 2359 | return newAvatarId; | ||
| 2360 | } | ||
| 2361 | |||
| 2362 | // #endregion | ||
| 2363 | |||
| 1902 | /** | 2364 | /** |
| 1903 | * Locks or unlocks the persona of the current chat. | 2365 | * Locks or unlocks the persona of the current chat. |
| 1904 | * @param {{type: string}} _args Named arguments | 2366 | * @param {{type: string}} _args Named arguments |
| @@ -1956,10 +2418,9 @@ async function setNameCallback({ mode = 'all' }, name) { | |||
| 1956 | 2418 | ||
| 1957 | // If the name matches a persona avatar, or a name, auto-select it | 2419 | // If the name matches a persona avatar, or a name, auto-select it |
| 1958 | if (['lookup', 'all'].includes(mode)) { | 2420 | if (['lookup', 'all'].includes(mode)) { |
| 1959 | let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1]; | 2421 | const persona = findPersona({ name }); |
| 1960 | if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1]; | ||
| 1961 | if (persona) { | 2422 | if (persona) { |
| 1962 | await autoSelectPersona(persona); | 2423 | await autoSelectPersona(persona.name, { personaKey: persona.avatar }); |
| 1963 | return ''; | 2424 | return ''; |
| 1964 | } else if (mode === 'lookup') { | 2425 | } else if (mode === 'lookup') { |
| 1965 | toastr.warning(`Persona ${name} not found`); | 2426 | toastr.warning(`Persona ${name} not found`); |
| @@ -2007,22 +2468,343 @@ function userMessageNamesEnumProvider() { | |||
| 2007 | } | 2468 | } |
| 2008 | 2469 | ||
| 2009 | function registerPersonaSlashCommands() { | 2470 | function registerPersonaSlashCommands() { |
| 2471 | // Shared persona field definitions for persona CRUD commands | ||
| 2472 | const getPersonaFieldArgs = ({ requiredFields = [] } = {}) => [ | ||
| 2473 | SlashCommandNamedArgument.fromProps({ | ||
| 2474 | name: 'name', | ||
| 2475 | description: t`The name of the persona`, | ||
| 2476 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2477 | isRequired: requiredFields.includes('name'), | ||
| 2478 | }), | ||
| 2479 | SlashCommandNamedArgument.fromProps({ | ||
| 2480 | name: 'description', | ||
| 2481 | description: t`The persona description (sent with messages for AI context)`, | ||
| 2482 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2483 | isRequired: requiredFields.includes('description'), | ||
| 2484 | }), | ||
| 2485 | SlashCommandNamedArgument.fromProps({ | ||
| 2486 | name: 'title', | ||
| 2487 | description: t`A display title for the persona (not sent to the AI, display only)`, | ||
| 2488 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2489 | isRequired: requiredFields.includes('title'), | ||
| 2490 | }), | ||
| 2491 | SlashCommandNamedArgument.fromProps({ | ||
| 2492 | name: 'avatar', | ||
| 2493 | description: t`Avatar image. Use "prompt" to open file picker, or provide a local ST file path or base64 data URL. Can also be the return value of /imagine.`, | ||
| 2494 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2495 | isRequired: requiredFields.includes('avatar'), | ||
| 2496 | enumList: [ | ||
| 2497 | new SlashCommandEnumValue('prompt', 'Open file picker to select an image', enumTypes.enum, '📁'), | ||
| 2498 | new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'), | ||
| 2499 | new SlashCommandEnumValue('backgrounds/...', 'Background image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'), | ||
| 2500 | new SlashCommandEnumValue('User Avatars/...', 'User avatar path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'), | ||
| 2501 | new SlashCommandEnumValue('assets/...', 'Asset file path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'), | ||
| 2502 | new SlashCommandEnumValue('user/images/...', 'User image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'), | ||
| 2503 | ], | ||
| 2504 | }), | ||
| 2505 | SlashCommandNamedArgument.fromProps({ | ||
| 2506 | name: 'avatarPromptResize', | ||
| 2507 | description: t`Whether to show the avatar resize/crop dialog when uploading. Ignored if "Never resize avatars" is enabled in settings.`, | ||
| 2508 | typeList: [ARGUMENT_TYPE.BOOLEAN], | ||
| 2509 | defaultValue: 'true', | ||
| 2510 | enumProvider: commonEnumProviders.boolean('trueFalse'), | ||
| 2511 | }), | ||
| 2512 | SlashCommandNamedArgument.fromProps({ | ||
| 2513 | name: 'descriptionPosition', | ||
| 2514 | description: t`Where to inject the persona description in the prompt`, | ||
| 2515 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2516 | enumList: [ | ||
| 2517 | new SlashCommandEnumValue('inPrompt', t`In Prompt (default)`, enumTypes.enum), | ||
| 2518 | new SlashCommandEnumValue('topAN', t`Top of Author's Note`, enumTypes.enum), | ||
| 2519 | new SlashCommandEnumValue('bottomAN', t`Bottom of Author's Note`, enumTypes.enum), | ||
| 2520 | new SlashCommandEnumValue('atDepth', t`At a specific depth (uses descriptionDepth and descriptionRole)`, enumTypes.enum), | ||
| 2521 | new SlashCommandEnumValue('none', t`None (don't inject)`, enumTypes.enum), | ||
| 2522 | ], | ||
| 2523 | }), | ||
| 2524 | SlashCommandNamedArgument.fromProps({ | ||
| 2525 | name: 'descriptionDepth', | ||
| 2526 | description: t`Depth for the persona description (when position is "atDepth")`, | ||
| 2527 | typeList: [ARGUMENT_TYPE.NUMBER], | ||
| 2528 | }), | ||
| 2529 | SlashCommandNamedArgument.fromProps({ | ||
| 2530 | name: 'descriptionRole', | ||
| 2531 | description: t`Role for the persona description (when position is "atDepth")`, | ||
| 2532 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2533 | enumList: commonEnumProviders.messageRoles(), | ||
| 2534 | }), | ||
| 2535 | SlashCommandNamedArgument.fromProps({ | ||
| 2536 | name: 'lorebook', | ||
| 2537 | description: t`The name of the lorebook/world info to attach to this persona`, | ||
| 2538 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2539 | enumProvider: commonEnumProviders.worlds, | ||
| 2540 | }), | ||
| 2541 | ]; | ||
| 2542 | |||
| 2543 | // Shared persona target argument (for commands that operate on an existing persona) | ||
| 2544 | const personaTargetArg = SlashCommandNamedArgument.fromProps({ | ||
| 2545 | name: 'persona', | ||
| 2546 | description: t`Persona name or avatar key. If not provided, uses the currently active persona.`, | ||
| 2547 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2548 | enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }), | ||
| 2549 | }); | ||
| 2550 | |||
| 2551 | // ======================== | ||
| 2552 | // New CRUD commands | ||
| 2553 | // ======================== | ||
| 2554 | |||
| 2555 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | ||
| 2556 | name: 'persona-create', | ||
| 2557 | callback: createPersonaCallback, | ||
| 2558 | returns: t`the avatar key (unique identifier) of the created persona`, | ||
| 2559 | namedArgumentList: [ | ||
| 2560 | ...getPersonaFieldArgs({ requiredFields: ['name'] }), | ||
| 2561 | SlashCommandNamedArgument.fromProps({ | ||
| 2562 | name: 'select', | ||
| 2563 | description: t`Whether to select/activate the persona after creation`, | ||
| 2564 | typeList: [ARGUMENT_TYPE.BOOLEAN], | ||
| 2565 | defaultValue: 'true', | ||
| 2566 | enumProvider: commonEnumProviders.boolean('trueFalse'), | ||
| 2567 | }), | ||
| 2568 | ], | ||
| 2569 | helpString: ` | ||
| 2570 | <div> | ||
| 2571 | ${t`Creates a new persona with the specified attributes. Returns the avatar key of the created persona.`} | ||
| 2572 | </div> | ||
| 2573 | <div> | ||
| 2574 | <strong>${t`Required arguments:`}</strong> | ||
| 2575 | <ul> | ||
| 2576 | <li><code>name</code> – ${t`The persona's display name.`}</li> | ||
| 2577 | </ul> | ||
| 2578 | </div> | ||
| 2579 | <div> | ||
| 2580 | <strong>${t`Note on avatar:`}</strong> | ||
| 2581 | ${t`The <code>avatar</code> argument accepts <code>prompt</code> to open a file picker, a local ST file path, or a base64 data URL. Can also be the return value of <code>/imagine</code>. If not provided, a default avatar will be used.`} | ||
| 2582 | </div> | ||
| 2583 | <div> | ||
| 2584 | <strong>${t`Example:`}</strong> | ||
| 2585 | <ul> | ||
| 2586 | <li> | ||
| 2587 | <pre><code>/persona-create name="Alice" description="A curious adventurer"</code></pre> | ||
| 2588 | </li> | ||
| 2589 | <li> | ||
| 2590 | <pre><code>/persona-create name="Bob" avatar=prompt lorebook="detective_lore" select=false</code></pre> | ||
| 2591 | </li> | ||
| 2592 | <li> | ||
| 2593 | <pre><code>/imagine portrait of an elf | /persona-create name="Elf" avatar="{{pipe}}"</code></pre> | ||
| 2594 | </li> | ||
| 2595 | </ul> | ||
| 2596 | </div> | ||
| 2597 | `, | ||
| 2598 | })); | ||
| 2599 | |||
| 2600 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | ||
| 2601 | name: 'persona-update', | ||
| 2602 | callback: updatePersonaCallback, | ||
| 2603 | returns: t`the avatar key of the updated persona`, | ||
| 2604 | namedArgumentList: [ | ||
| 2605 | personaTargetArg, | ||
| 2606 | ...getPersonaFieldArgs(), | ||
| 2607 | ], | ||
| 2608 | helpString: ` | ||
| 2609 | <div> | ||
| 2610 | ${t`Updates an existing persona's attributes. Only the provided fields are changed; others are left untouched.`} | ||
| 2611 | </div> | ||
| 2612 | <div> | ||
| 2613 | ${t`If no <code>persona</code> argument is provided, updates the currently active persona.`} | ||
| 2614 | </div> | ||
| 2615 | <div> | ||
| 2616 | <strong>${t`Example:`}</strong> | ||
| 2617 | <ul> | ||
| 2618 | <li> | ||
| 2619 | <pre><code>/persona-update description="An updated description"</code></pre> | ||
| 2620 | ${t`Updates the current persona's description.`} | ||
| 2621 | </li> | ||
| 2622 | <li> | ||
| 2623 | <pre><code>/persona-update persona="Alice" name="Alice 2.0" descriptionPosition=atDepth descriptionDepth=3</code></pre> | ||
| 2624 | ${t`Renames Alice and sets her description to inject at depth 3.`} | ||
| 2625 | </li> | ||
| 2626 | <li> | ||
| 2627 | <pre><code>/imagine portrait | /persona-update avatar="{{pipe}}"</code></pre> | ||
| 2628 | ${t`Generates an image and sets it as the current persona's avatar.`} | ||
| 2629 | </li> | ||
| 2630 | </ul> | ||
| 2631 | </div> | ||
| 2632 | `, | ||
| 2633 | })); | ||
| 2634 | |||
| 2635 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | ||
| 2636 | name: 'persona-get', | ||
| 2637 | aliases: ['persona-data'], | ||
| 2638 | callback: getPersonaDataCallback, | ||
| 2639 | returns: t`persona data as JSON or a specific field value`, | ||
| 2640 | namedArgumentList: [ | ||
| 2641 | personaTargetArg, | ||
| 2642 | SlashCommandNamedArgument.fromProps({ | ||
| 2643 | name: 'field', | ||
| 2644 | description: t`Specific field to retrieve. If not provided, returns the entire persona data as JSON.`, | ||
| 2645 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2646 | enumList: [ | ||
| 2647 | new SlashCommandEnumValue('name', t`Persona name`, enumTypes.enum, enumIcons.persona), | ||
| 2648 | new SlashCommandEnumValue('description', t`Persona description`, enumTypes.enum, enumIcons.default), | ||
| 2649 | new SlashCommandEnumValue('title', t`Display title`, enumTypes.enum, enumIcons.default), | ||
| 2650 | new SlashCommandEnumValue('position', t`Description position (numeric)`, enumTypes.enum, enumIcons.default), | ||
| 2651 | new SlashCommandEnumValue('depth', t`Description depth`, enumTypes.enum, enumIcons.default), | ||
| 2652 | new SlashCommandEnumValue('role', t`Description role (numeric)`, enumTypes.enum, enumIcons.default), | ||
| 2653 | new SlashCommandEnumValue('lorebook', t`Attached lorebook name`, enumTypes.enum, enumIcons.world), | ||
| 2654 | new SlashCommandEnumValue('avatar', t`Avatar filename (unique key)`, enumTypes.enum, enumIcons.persona), | ||
| 2655 | new SlashCommandEnumValue('default', t`Whether this is the default persona`, enumTypes.enum, enumIcons.default), | ||
| 2656 | new SlashCommandEnumValue('connections', t`Character/group connections (array)`, enumTypes.enum, enumIcons.character), | ||
| 2657 | ], | ||
| 2658 | }), | ||
| 2659 | SlashCommandNamedArgument.fromProps({ | ||
| 2660 | name: 'return', | ||
| 2661 | description: t`The way to return the result`, | ||
| 2662 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2663 | defaultValue: 'pipe', | ||
| 2664 | enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowObject: true, allowPopup: true, allowTextVersion: false }), | ||
| 2665 | }), | ||
| 2666 | ], | ||
| 2667 | helpString: ` | ||
| 2668 | <div> | ||
| 2669 | ${t`Retrieves persona data. Can return all data as JSON or a specific field value.`} | ||
| 2670 | </div> | ||
| 2671 | <div> | ||
| 2672 | ${t`If no <code>persona</code> argument is provided, uses the currently active persona.`} | ||
| 2673 | </div> | ||
| 2674 | <div> | ||
| 2675 | <strong>${t`Example:`}</strong> | ||
| 2676 | <ul> | ||
| 2677 | <li> | ||
| 2678 | <pre><code>/persona-get field=description | /echo</code></pre> | ||
| 2679 | ${t`Outputs the current persona's description.`} | ||
| 2680 | </li> | ||
| 2681 | <li> | ||
| 2682 | <pre><code>/persona-get persona="Alice" field=name</code></pre> | ||
| 2683 | ${t`Returns Alice's persona name.`} | ||
| 2684 | </li> | ||
| 2685 | <li> | ||
| 2686 | <pre><code>/persona-get return=object | /json-get key=avatar</code></pre> | ||
| 2687 | ${t`Returns the current persona's full data as an object, then extracts the avatar key.`} | ||
| 2688 | </li> | ||
| 2689 | </ul> | ||
| 2690 | </div> | ||
| 2691 | `, | ||
| 2692 | })); | ||
| 2693 | |||
| 2694 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | ||
| 2695 | name: 'persona-delete', | ||
| 2696 | callback: deletePersonaCallback, | ||
| 2697 | returns: t`true if the persona was deleted, false otherwise`, | ||
| 2698 | namedArgumentList: [ | ||
| 2699 | personaTargetArg, | ||
| 2700 | SlashCommandNamedArgument.fromProps({ | ||
| 2701 | name: 'silent', | ||
| 2702 | description: t`Skip the confirmation popup`, | ||
| 2703 | typeList: [ARGUMENT_TYPE.BOOLEAN], | ||
| 2704 | defaultValue: 'false', | ||
| 2705 | enumProvider: commonEnumProviders.boolean('trueFalse'), | ||
| 2706 | }), | ||
| 2707 | ], | ||
| 2708 | helpString: ` | ||
| 2709 | <div> | ||
| 2710 | ${t`Deletes a persona and its avatar from the system.`} | ||
| 2711 | </div> | ||
| 2712 | <div> | ||
| 2713 | ${t`If no <code>persona</code> argument is provided, deletes the currently active persona.`} | ||
| 2714 | </div> | ||
| 2715 | <div> | ||
| 2716 | <strong>⚠️ ${t`Warning:`}</strong> ${t`This action is irreversible. All data associated with the persona will be lost.`} | ||
| 2717 | </div> | ||
| 2718 | <div> | ||
| 2719 | <strong>${t`Example:`}</strong> | ||
| 2720 | <ul> | ||
| 2721 | <li> | ||
| 2722 | <pre><code>/persona-delete</code></pre> | ||
| 2723 | ${t`Deletes the current persona (shows confirmation popup).`} | ||
| 2724 | </li> | ||
| 2725 | <li> | ||
| 2726 | <pre><code>/persona-delete persona="Bob" silent=true</code></pre> | ||
| 2727 | ${t`Deletes Bob without confirmation.`} | ||
| 2728 | </li> | ||
| 2729 | </ul> | ||
| 2730 | </div> | ||
| 2731 | `, | ||
| 2732 | })); | ||
| 2733 | |||
| 2734 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | ||
| 2735 | name: 'persona-duplicate', | ||
| 2736 | callback: duplicatePersonaCallback, | ||
| 2737 | returns: t`the avatar key (unique identifier) of the duplicated persona`, | ||
| 2738 | namedArgumentList: [ | ||
| 2739 | personaTargetArg, | ||
| 2740 | SlashCommandNamedArgument.fromProps({ | ||
| 2741 | name: 'select', | ||
| 2742 | description: t`Whether to select/activate the duplicated persona after creation`, | ||
| 2743 | typeList: [ARGUMENT_TYPE.BOOLEAN], | ||
| 2744 | defaultValue: 'false', | ||
| 2745 | enumProvider: commonEnumProviders.boolean('trueFalse'), | ||
| 2746 | }), | ||
| 2747 | ], | ||
| 2748 | helpString: ` | ||
| 2749 | <div> | ||
| 2750 | ${t`Duplicates a persona including all its data and avatar. Returns the avatar key of the new persona.`} | ||
| 2751 | </div> | ||
| 2752 | <div> | ||
| 2753 | ${t`Use <code>/persona-update</code> afterwards to rename or modify the duplicated persona's fields.`} | ||
| 2754 | </div> | ||
| 2755 | <div> | ||
| 2756 | <strong>${t`Example:`}</strong> | ||
| 2757 | <ul> | ||
| 2758 | <li> | ||
| 2759 | <pre><code>/persona-duplicate</code></pre> | ||
| 2760 | ${t`Duplicates the currently active persona.`} | ||
| 2761 | </li> | ||
| 2762 | <li> | ||
| 2763 | <pre><code>/persona-duplicate persona="Alice" select=true</code></pre> | ||
| 2764 | ${t`Duplicates Alice and selects the new persona.`} | ||
| 2765 | </li> | ||
| 2766 | <li> | ||
| 2767 | <pre><code>/persona-duplicate | /persona-update persona="{{pipe}}" name="Clone"</code></pre> | ||
| 2768 | ${t`Duplicates the current persona, then renames the clone.`} | ||
| 2769 | </li> | ||
| 2770 | </ul> | ||
| 2771 | </div> | ||
| 2772 | `, | ||
| 2773 | })); | ||
| 2774 | |||
| 2775 | // ======================== | ||
| 2776 | // Existing commands (enhanced help strings) | ||
| 2777 | // ======================== | ||
| 2778 | |||
| 2010 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2779 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2011 | name: 'persona-lock', | 2780 | name: 'persona-lock', |
| 2012 | aliases: ['lock', 'bind'], | 2781 | aliases: ['lock', 'bind'], |
| 2013 | callback: lockPersonaCallback, | 2782 | callback: lockPersonaCallback, |
| 2014 | returns: 'The current lock state for the given type', | 2783 | returns: t`The current lock state for the given type`, |
| 2015 | helpString: 'Locks/unlocks a persona (name and avatar) to the current chat. Gets the current lock state for the given type if no state is provided.', | 2784 | helpString: ` |
| 2785 | <div> | ||
| 2786 | ${t`Locks/unlocks the current persona to a chat, character, or as the default. Returns the lock state if no value is provided.`} | ||
| 2787 | </div> | ||
| 2788 | <div> | ||
| 2789 | <strong>${t`Example:`}</strong> | ||
| 2790 | <ul> | ||
| 2791 | <li><pre><code>/persona-lock on</code></pre> ${t`Locks persona to this chat.`}</li> | ||
| 2792 | <li><pre><code>/persona-lock type=character on</code></pre> ${t`Locks persona to the current character.`}</li> | ||
| 2793 | <li><pre><code>/persona-lock type=default on</code></pre> ${t`Sets persona as the default for new chats.`}</li> | ||
| 2794 | <li><pre><code>/persona-lock</code></pre> ${t`Returns whether the persona is locked to this chat.`}</li> | ||
| 2795 | </ul> | ||
| 2796 | </div> | ||
| 2797 | `, | ||
| 2016 | namedArgumentList: [ | 2798 | namedArgumentList: [ |
| 2017 | SlashCommandNamedArgument.fromProps({ | 2799 | SlashCommandNamedArgument.fromProps({ |
| 2018 | name: 'type', | 2800 | name: 'type', |
| 2019 | description: 'The type of the lock, where it should apply to', | 2801 | description: t`The type of the lock, where it should apply to`, |
| 2020 | typeList: [ARGUMENT_TYPE.STRING], | 2802 | typeList: [ARGUMENT_TYPE.STRING], |
| 2021 | defaultValue: 'chat', | 2803 | defaultValue: 'chat', |
| 2022 | enumList: [ | 2804 | enumList: [ |
| 2023 | new SlashCommandEnumValue('chat', 'Lock the persona to the current chat.'), | 2805 | new SlashCommandEnumValue('chat', t`Lock the persona to the current chat.`), |
| 2024 | new SlashCommandEnumValue('character', 'Lock this persona to the currently selected character. If the setting is enabled, multiple personas can be locked to the same character.'), | 2806 | new SlashCommandEnumValue('character', t`Lock this persona to the currently selected character. If the setting is enabled, multiple personas can be locked to the same character.`), |
| 2025 | new SlashCommandEnumValue('default', 'Lock this persona as the default persona for all new chats.'), | 2807 | new SlashCommandEnumValue('default', t`Lock this persona as the default persona for all new chats.`), |
| 2026 | ], | 2808 | ], |
| 2027 | }), | 2809 | }), |
| 2028 | ], | 2810 | ], |
| @@ -2034,26 +2816,49 @@ function registerPersonaSlashCommands() { | |||
| 2034 | }), | 2816 | }), |
| 2035 | ], | 2817 | ], |
| 2036 | })); | 2818 | })); |
| 2819 | |||
| 2037 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2820 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2038 | name: 'persona-set', | 2821 | name: 'persona-set', |
| 2039 | callback: setNameCallback, | 2822 | callback: setNameCallback, |
| 2040 | aliases: ['persona', 'name'], | 2823 | aliases: ['persona', 'name'], |
| 2041 | namedArgumentList: [ | 2824 | namedArgumentList: [ |
| 2042 | new SlashCommandNamedArgument( | 2825 | SlashCommandNamedArgument.fromProps({ |
| 2043 | 'mode', 'The mode for persona selection. ("lookup" = search for existing persona, "temp" = create a temporary name, set a temporary name, "all" = allow both in the same command)', | 2826 | name: 'mode', |
| 2044 | [ARGUMENT_TYPE.STRING], false, false, 'all', ['lookup', 'temp', 'all'], | 2827 | description: t`The mode for persona selection`, |
| 2045 | ), | 2828 | typeList: [ARGUMENT_TYPE.STRING], |
| 2829 | defaultValue: 'all', | ||
| 2830 | enumList: [ | ||
| 2831 | new SlashCommandEnumValue('lookup', t`Search for an existing persona only`), | ||
| 2832 | new SlashCommandEnumValue('temp', t`Set a temporary name only (no persona lookup)`), | ||
| 2833 | new SlashCommandEnumValue('all', t`Try persona lookup first, fall back to temporary name`), | ||
| 2834 | ], | ||
| 2835 | }), | ||
| 2046 | ], | 2836 | ], |
| 2047 | unnamedArgumentList: [ | 2837 | unnamedArgumentList: [ |
| 2048 | SlashCommandArgument.fromProps({ | 2838 | SlashCommandArgument.fromProps({ |
| 2049 | description: 'persona name', | 2839 | description: 'persona name', |
| 2050 | typeList: [ARGUMENT_TYPE.STRING], | 2840 | typeList: [ARGUMENT_TYPE.STRING], |
| 2051 | isRequired: true, | 2841 | isRequired: true, |
| 2052 | enumProvider: commonEnumProviders.personas, | 2842 | enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }), |
| 2053 | }), | 2843 | }), |
| 2054 | ], | 2844 | ], |
| 2055 | helpString: 'Selects the given persona with its name and avatar (by name or avatar url). If no matching persona exists, applies a temporary name.', | 2845 | helpString: ` |
| 2846 | <div> | ||
| 2847 | ${t`Selects an existing persona by name or avatar key, or sets a temporary user name.`} | ||
| 2848 | </div> | ||
| 2849 | <div> | ||
| 2850 | ${t`If a matching persona exists, it will be selected with its name and avatar. Otherwise (in "all" or "temp" mode), only the display name is changed temporarily.`} | ||
| 2851 | </div> | ||
| 2852 | <div> | ||
| 2853 | <strong>${t`Example:`}</strong> | ||
| 2854 | <ul> | ||
| 2855 | <li><pre><code>/persona-set Alice</code></pre> ${t`Selects persona "Alice", or sets name to "Alice" if not found.`}</li> | ||
| 2856 | <li><pre><code>/persona-set mode=lookup Alice</code></pre> ${t`Only selects if persona "Alice" exists.`}</li> | ||
| 2857 | </ul> | ||
| 2858 | </div> | ||
| 2859 | `, | ||
| 2056 | })); | 2860 | })); |
| 2861 | |||
| 2057 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2862 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2058 | name: 'persona-sync', | 2863 | name: 'persona-sync', |
| 2059 | aliases: ['sync'], | 2864 | aliases: ['sync'], |
| @@ -68,6 +68,7 @@ import { bindModelTemplates } from './chat-templates.js'; | |||
| 68 | import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js'; | 68 | import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js'; |
| 69 | import { t } from './i18n.js'; | 69 | import { t } from './i18n.js'; |
| 70 | import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js'; | 70 | import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js'; |
| 71 | import { persona_description_positions as _persona_description_positions } from './personas.js'; | ||
| 71 | 72 | ||
| 72 | export const toastPositionClasses = [ | 73 | export const toastPositionClasses = [ |
| 73 | 'toast-top-left', | 74 | 'toast-top-left', |
| @@ -110,17 +111,7 @@ export const send_on_enter_options = { | |||
| 110 | ENABLED: 1, | 111 | ENABLED: 1, |
| 111 | }; | 112 | }; |
| 112 | 113 | ||
| 113 | export const persona_description_positions = { | 114 | export const persona_description_positions = _persona_description_positions; |
| 114 | IN_PROMPT: 0, | ||
| 115 | /** | ||
| 116 | * @deprecated Use persona_description_positions.IN_PROMPT instead. | ||
| 117 | */ | ||
| 118 | AFTER_CHAR: 1, | ||
| 119 | TOP_AN: 2, | ||
| 120 | BOTTOM_AN: 3, | ||
| 121 | AT_DEPTH: 4, | ||
| 122 | NONE: 9, | ||
| 123 | }; | ||
| 124 | 115 | ||
| 125 | export const power_user = { | 116 | export const power_user = { |
| 126 | charListGrid: false, | 117 | charListGrid: false, |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { Fuse, DOMPurify } from '../lib.js'; | 1 | import { Fuse, DOMPurify } from '../lib.js'; |
| 2 | import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js'; | 2 | import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, resolveAvatarData } from './utils.js'; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | Generate, | 5 | Generate, |
| @@ -88,7 +88,7 @@ import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortC | |||
| 88 | import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; | 88 | import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; |
| 89 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; | 89 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 90 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; | 90 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 91 | import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; | 91 | import { commonEnumProviders, enumIcons, commonEnumMatchProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 92 | import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; | 92 | import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; |
| 93 | import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js'; | 93 | import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js'; |
| 94 | import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js'; | 94 | import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js'; |
| @@ -768,23 +768,6 @@ export function initDefaultSlashCommands() { | |||
| 768 | `, | 768 | `, |
| 769 | })); | 769 | })); |
| 770 | 770 | ||
| 771 | /** | ||
| 772 | * Provides autocomplete matching for folder names. | ||
| 773 | * Matches if the input starts with the check or vice versa (case-insensitive). | ||
| 774 | * @param {string} input - The input string to match against | ||
| 775 | * @param {string} check - The check string to match with | ||
| 776 | * @param {object} [options={}] - Options | ||
| 777 | * @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty | ||
| 778 | * @returns {boolean} - True if the strings match according to the folder matching rules | ||
| 779 | */ | ||
| 780 | function folderEnumMatchProvider(input, check, { trueOnEmpty = true } = {}) { | ||
| 781 | if (!check) return false; | ||
| 782 | if (!input) return trueOnEmpty; | ||
| 783 | const inputLower = input.toLowerCase(); | ||
| 784 | const checkLower = check.toLowerCase(); | ||
| 785 | return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower); | ||
| 786 | } | ||
| 787 | |||
| 788 | // Shared character field definitions for char CRUD commands | 771 | // Shared character field definitions for char CRUD commands |
| 789 | const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [ | 772 | const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [ |
| 790 | SlashCommandNamedArgument.fromProps({ | 773 | SlashCommandNamedArgument.fromProps({ |
| @@ -873,11 +856,11 @@ export function initDefaultSlashCommands() { | |||
| 873 | isRequired: requiredFields.includes('avatar'), | 856 | isRequired: requiredFields.includes('avatar'), |
| 874 | enumList: [ | 857 | enumList: [ |
| 875 | new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'), | 858 | new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'), |
| 876 | new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'characters/'), () => 'characters/'), | 859 | new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'), |
| 877 | new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'backgrounds/'), () => 'backgrounds/'), | 860 | new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'), |
| 878 | new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'User Avatars/'), () => 'User Avatars/'), | 861 | new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'), |
| 879 | new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'assets/'), () => 'assets/'), | 862 | new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'), |
| 880 | new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'user/images/'), () => 'user/images/'), | 863 | new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'), |
| 881 | ], | 864 | ], |
| 882 | }), | 865 | }), |
| 883 | SlashCommandNamedArgument.fromProps({ | 866 | SlashCommandNamedArgument.fromProps({ |
| @@ -1241,7 +1224,7 @@ export function initDefaultSlashCommands() { | |||
| 1241 | modifyAt = chat.length + modifyAt; | 1224 | modifyAt = chat.length + modifyAt; |
| 1242 | } | 1225 | } |
| 1243 | return chat[modifyAt]?.is_user | 1226 | return chat[modifyAt]?.is_user |
| 1244 | ? commonEnumProviders.personas() | 1227 | ? commonEnumProviders.personas()() |
| 1245 | : commonEnumProviders.characters('character')(); | 1228 | : commonEnumProviders.characters('character')(); |
| 1246 | }, | 1229 | }, |
| 1247 | }), | 1230 | }), |
| @@ -1769,7 +1752,7 @@ export function initDefaultSlashCommands() { | |||
| 1769 | description: t`display name`, | 1752 | description: t`display name`, |
| 1770 | typeList: [ARGUMENT_TYPE.STRING], | 1753 | typeList: [ARGUMENT_TYPE.STRING], |
| 1771 | defaultValue: '{{user}}', | 1754 | defaultValue: '{{user}}', |
| 1772 | enumProvider: commonEnumProviders.personas, | 1755 | enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }), |
| 1773 | }), | 1756 | }), |
| 1774 | SlashCommandNamedArgument.fromProps({ | 1757 | SlashCommandNamedArgument.fromProps({ |
| 1775 | name: 'return', | 1758 | name: 'return', |
| @@ -5034,23 +5017,6 @@ async function triggerGenerationCallback(args, value) { | |||
| 5034 | 5017 | ||
| 5035 | return ''; | 5018 | return ''; |
| 5036 | } | 5019 | } |
| 5037 | /** | ||
| 5038 | * Find persona by name. | ||
| 5039 | * @param {string} name Name to search for | ||
| 5040 | * @returns {string} Persona name | ||
| 5041 | */ | ||
| 5042 | function findPersonaByName(name) { | ||
| 5043 | if (!name) { | ||
| 5044 | return null; | ||
| 5045 | } | ||
| 5046 | |||
| 5047 | for (const persona of Object.entries(power_user.personas)) { | ||
| 5048 | if (equalsIgnoreCaseAndAccents(persona[1], name)) { | ||
| 5049 | return persona[0]; | ||
| 5050 | } | ||
| 5051 | } | ||
| 5052 | return null; | ||
| 5053 | } | ||
| 5054 | 5020 | ||
| 5055 | async function sendUserMessageCallback(args, text) { | 5021 | async function sendUserMessageCallback(args, text) { |
| 5056 | text = String(text ?? '').trim(); | 5022 | text = String(text ?? '').trim(); |
| @@ -5068,7 +5034,7 @@ async function sendUserMessageCallback(args, text) { | |||
| 5068 | let message; | 5034 | let message; |
| 5069 | if ('name' in args) { | 5035 | if ('name' in args) { |
| 5070 | const name = args.name || ''; | 5036 | const name = args.name || ''; |
| 5071 | const avatar = findPersonaByName(name) || user_avatar; | 5037 | const avatar = findPersona({ name })?.avatar || user_avatar; |
| 5072 | message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar); | 5038 | message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar); |
| 5073 | } else { | 5039 | } else { |
| 5074 | message = await sendMessageAsUser(text, bias, insertAt, compact); | 5040 | message = await sendMessageAsUser(text, bias, insertAt, compact); |
| @@ -5147,105 +5113,6 @@ async function openChat(chid) { | |||
| 5147 | } | 5113 | } |
| 5148 | 5114 | ||
| 5149 | /** | 5115 | /** |
| 5150 | * Opens a file picker dialog for selecting an image. | ||
| 5151 | * @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled | ||
| 5152 | */ | ||
| 5153 | async function promptForAvatarFile() { | ||
| 5154 | return new Promise(resolve => { | ||
| 5155 | const input = document.createElement('input'); | ||
| 5156 | input.type = 'file'; | ||
| 5157 | input.accept = supportedImageMimeTypes.join(','); | ||
| 5158 | input.onchange = async (e) => { | ||
| 5159 | if (!(e.target instanceof HTMLInputElement)) { | ||
| 5160 | return ''; | ||
| 5161 | } | ||
| 5162 | const file = e.target?.files?.[0]; | ||
| 5163 | if (!file) { | ||
| 5164 | resolve(null); | ||
| 5165 | return; | ||
| 5166 | } | ||
| 5167 | try { | ||
| 5168 | const converted = await ensureImageFormatSupported(file); | ||
| 5169 | const base64 = await getBase64Async(converted); | ||
| 5170 | resolve(base64); | ||
| 5171 | } catch (error) { | ||
| 5172 | console.error('Error processing selected image:', error); | ||
| 5173 | toastr.error(t`Failed to process selected image: ${error.message}`); | ||
| 5174 | resolve(null); | ||
| 5175 | } | ||
| 5176 | }; | ||
| 5177 | input.oncancel = () => resolve(null); | ||
| 5178 | input.click(); | ||
| 5179 | }); | ||
| 5180 | } | ||
| 5181 | |||
| 5182 | /** | ||
| 5183 | * Resolves avatar data from various input formats (base64, local path, or prompt). | ||
| 5184 | * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path | ||
| 5185 | * @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled | ||
| 5186 | */ | ||
| 5187 | async function resolveAvatarData(input) { | ||
| 5188 | if (!input || typeof input !== 'string') { | ||
| 5189 | return null; | ||
| 5190 | } | ||
| 5191 | |||
| 5192 | const trimmed = input.trim(); | ||
| 5193 | |||
| 5194 | // Special value "prompt" opens file picker | ||
| 5195 | if (trimmed.toLowerCase() === 'prompt') { | ||
| 5196 | return await promptForAvatarFile(); | ||
| 5197 | } | ||
| 5198 | |||
| 5199 | // Already a base64 data URL | ||
| 5200 | if (trimmed.startsWith('data:image/')) { | ||
| 5201 | return trimmed; | ||
| 5202 | } | ||
| 5203 | |||
| 5204 | // External URLs are not supported | ||
| 5205 | if (isExternalUrl(trimmed)) { | ||
| 5206 | toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`); | ||
| 5207 | return null; | ||
| 5208 | } | ||
| 5209 | // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin | ||
| 5210 | // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/* | ||
| 5211 | // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png) | ||
| 5212 | if (trimmed.includes('/') || trimmed.endsWith('.png')) { | ||
| 5213 | try { | ||
| 5214 | // Construct the URL to fetch the local file | ||
| 5215 | let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; | ||
| 5216 | // Handle same-origin URLs | ||
| 5217 | if (trimmed.startsWith(window.location.origin)) { | ||
| 5218 | url = new URL(trimmed).pathname; | ||
| 5219 | } | ||
| 5220 | // If there is no subfolder, we guess this should be a character image | ||
| 5221 | if (!url.includes('/', 1)) { | ||
| 5222 | url = '/characters/' + trimmed; | ||
| 5223 | } | ||
| 5224 | |||
| 5225 | const response = await fetch(url); | ||
| 5226 | if (!response.ok) { | ||
| 5227 | throw new Error(`File not found or inaccessible: ${response.status}`); | ||
| 5228 | } | ||
| 5229 | const blob = await response.blob(); | ||
| 5230 | if (!blob.type.startsWith('image/')) { | ||
| 5231 | throw new Error('File is not an image'); | ||
| 5232 | } | ||
| 5233 | const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type })); | ||
| 5234 | return await getBase64Async(converted); | ||
| 5235 | } catch (error) { | ||
| 5236 | console.error('Error fetching local avatar:', error); | ||
| 5237 | toastr.warning(t`Failed to load avatar from path: ${error.message}`); | ||
| 5238 | return null; | ||
| 5239 | } | ||
| 5240 | } | ||
| 5241 | |||
| 5242 | // Unknown format | ||
| 5243 | console.warn('Unknown avatar format:', trimmed.substring(0, 50)); | ||
| 5244 | toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`); | ||
| 5245 | return null; | ||
| 5246 | } | ||
| 5247 | |||
| 5248 | /** | ||
| 5249 | * Uploads an avatar image to a character. | 5116 | * Uploads an avatar image to a character. |
| 5250 | * @param {string} avatarKey - The character's avatar filename (e.g., "name.png") | 5117 | * @param {string} avatarKey - The character's avatar filename (e.g., "name.png") |
| 5251 | * @param {string} base64Data - Base64 data URL of the image | 5118 | * @param {string} base64Data - Base64 data URL of the image |
| @@ -214,9 +214,13 @@ export const commonEnumProviders = { | |||
| 214 | /** | 214 | /** |
| 215 | * All possible personas | 215 | * All possible personas |
| 216 | * | 216 | * |
| 217 | * @returns {SlashCommandEnumValue[]} | 217 | * @returns {() => SlashCommandEnumValue[]} |
| 218 | */ | 218 | */ |
| 219 | personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)), | 219 | personas: ({ allowPersonaKey = false } = {}) => () => Object.entries(power_user.personas).map(([personaKey, personaName]) => { |
| 220 | const existsMultiple = Object.values(power_user.personas).filter(p => p === personaName).length > 1; | ||
| 221 | const returnValue = allowPersonaKey && existsMultiple ? personaKey : personaName; | ||
| 222 | return new SlashCommandEnumValue(returnValue, allowPersonaKey && existsMultiple ? personaName : null, enumTypes.name, enumIcons.persona); | ||
| 223 | }), | ||
| 220 | 224 | ||
| 221 | /** | 225 | /** |
| 222 | * All possible tags, or only those that have been assigned | 226 | * All possible tags, or only those that have been assigned |
| @@ -234,9 +238,9 @@ export const commonEnumProviders = { | |||
| 234 | * All possible tags for a given char/group entity | 238 | * All possible tags for a given char/group entity |
| 235 | * | 239 | * |
| 236 | * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show | 240 | * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show |
| 237 | * @returns {() => SlashCommandEnumValue[]} | 241 | * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]} |
| 238 | */ | 242 | */ |
| 239 | tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor) => { | 243 | tagsForChar: (mode = 'all') => (executor, _scope) => { |
| 240 | // Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags. | 244 | // Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags. |
| 241 | const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value; | 245 | const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value; |
| 242 | if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures'); | 246 | if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures'); |
| @@ -347,3 +351,28 @@ export const commonEnumProviders = { | |||
| 347 | ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)), | 351 | ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)), |
| 348 | ], | 352 | ], |
| 349 | }; | 353 | }; |
| 354 | |||
| 355 | |||
| 356 | /** | ||
| 357 | * A collection of common enum match providers | ||
| 358 | * | ||
| 359 | * Can be used on `SlashCommandEnumValue` and their `matchProvider` property. | ||
| 360 | */ | ||
| 361 | export const commonEnumMatchProviders = { | ||
| 362 | /** | ||
| 363 | * Provides autocomplete matching for folder-like enum values. | ||
| 364 | * Matches if the input starts with the check or vice versa (case-insensitive). | ||
| 365 | * @param {string} input - The input string to match against | ||
| 366 | * @param {string} check - The check string to match with | ||
| 367 | * @param {object} [options={}] - Options | ||
| 368 | * @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty | ||
| 369 | * @returns {boolean} - True if the strings match according to the folder matching rules | ||
| 370 | */ | ||
| 371 | folderEnum: (input, check, { trueOnEmpty = true } = {}) => { | ||
| 372 | if (!check) return false; | ||
| 373 | if (!input) return trueOnEmpty; | ||
| 374 | const inputLower = input.toLowerCase(); | ||
| 375 | const checkLower = check.toLowerCase(); | ||
| 376 | return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower); | ||
| 377 | }, | ||
| 378 | }; | ||
| @@ -1742,6 +1742,105 @@ export function loadFileToDocument(url, type) { | |||
| 1742 | } | 1742 | } |
| 1743 | 1743 | ||
| 1744 | /** | 1744 | /** |
| 1745 | * Opens a file picker dialog for selecting an image. | ||
| 1746 | * @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled | ||
| 1747 | */ | ||
| 1748 | export async function promptForAvatarFile() { | ||
| 1749 | return new Promise(resolve => { | ||
| 1750 | const input = document.createElement('input'); | ||
| 1751 | input.type = 'file'; | ||
| 1752 | input.accept = supportedImageMimeTypes.join(','); | ||
| 1753 | input.onchange = async (e) => { | ||
| 1754 | if (!(e.target instanceof HTMLInputElement)) { | ||
| 1755 | return ''; | ||
| 1756 | } | ||
| 1757 | const file = e.target?.files?.[0]; | ||
| 1758 | if (!file) { | ||
| 1759 | resolve(null); | ||
| 1760 | return; | ||
| 1761 | } | ||
| 1762 | try { | ||
| 1763 | const converted = await ensureImageFormatSupported(file); | ||
| 1764 | const base64 = await getBase64Async(converted); | ||
| 1765 | resolve(base64); | ||
| 1766 | } catch (error) { | ||
| 1767 | console.error('Error processing selected image:', error); | ||
| 1768 | toastr.error(t`Failed to process selected image: ${error.message}`); | ||
| 1769 | resolve(null); | ||
| 1770 | } | ||
| 1771 | }; | ||
| 1772 | input.oncancel = () => resolve(null); | ||
| 1773 | input.click(); | ||
| 1774 | }); | ||
| 1775 | } | ||
| 1776 | |||
| 1777 | /** | ||
| 1778 | * Resolves avatar data from various input formats (base64, local path, or prompt). | ||
| 1779 | * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path | ||
| 1780 | * @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled | ||
| 1781 | */ | ||
| 1782 | export async function resolveAvatarData(input) { | ||
| 1783 | if (!input || typeof input !== 'string') { | ||
| 1784 | return null; | ||
| 1785 | } | ||
| 1786 | |||
| 1787 | const trimmed = input.trim(); | ||
| 1788 | |||
| 1789 | // Special value "prompt" opens file picker | ||
| 1790 | if (trimmed.toLowerCase() === 'prompt') { | ||
| 1791 | return await promptForAvatarFile(); | ||
| 1792 | } | ||
| 1793 | |||
| 1794 | // Already a base64 data URL | ||
| 1795 | if (trimmed.startsWith('data:image/')) { | ||
| 1796 | return trimmed; | ||
| 1797 | } | ||
| 1798 | |||
| 1799 | // External URLs are not supported | ||
| 1800 | if (isExternalUrl(trimmed)) { | ||
| 1801 | toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`); | ||
| 1802 | return null; | ||
| 1803 | } | ||
| 1804 | // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin | ||
| 1805 | // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/* | ||
| 1806 | // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png) | ||
| 1807 | if (trimmed.includes('/') || trimmed.endsWith('.png')) { | ||
| 1808 | try { | ||
| 1809 | // Construct the URL to fetch the local file | ||
| 1810 | let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; | ||
| 1811 | // Handle same-origin URLs | ||
| 1812 | if (trimmed.startsWith(window.location.origin)) { | ||
| 1813 | url = new URL(trimmed).pathname; | ||
| 1814 | } | ||
| 1815 | // If there is no subfolder, we guess this should be a character image | ||
| 1816 | if (!url.includes('/', 1)) { | ||
| 1817 | url = '/characters/' + trimmed; | ||
| 1818 | } | ||
| 1819 | |||
| 1820 | const response = await fetch(url); | ||
| 1821 | if (!response.ok) { | ||
| 1822 | throw new Error(`File not found or inaccessible: ${response.status}`); | ||
| 1823 | } | ||
| 1824 | const blob = await response.blob(); | ||
| 1825 | if (!blob.type.startsWith('image/')) { | ||
| 1826 | throw new Error('File is not an image'); | ||
| 1827 | } | ||
| 1828 | const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type })); | ||
| 1829 | return await getBase64Async(converted); | ||
| 1830 | } catch (error) { | ||
| 1831 | console.error('Error fetching local avatar:', error); | ||
| 1832 | toastr.warning(t`Failed to load avatar from path: ${error.message}`); | ||
| 1833 | return null; | ||
| 1834 | } | ||
| 1835 | } | ||
| 1836 | |||
| 1837 | // Unknown format | ||
| 1838 | console.warn('Unknown avatar format:', trimmed.substring(0, 50)); | ||
| 1839 | toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`); | ||
| 1840 | return null; | ||
| 1841 | } | ||
| 1842 | |||
| 1843 | /** | ||
| 1745 | * An array of all supported image MIME types. | 1844 | * An array of all supported image MIME types. |
| 1746 | */ | 1845 | */ |
| 1747 | export const supportedImageMimeTypes = Object.freeze([ | 1846 | export const supportedImageMimeTypes = Object.freeze([ |