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 | 22 | setUserName, |
| 23 | 23 | this_chid, |
| 24 | 24 | } from '../script.js'; |
| 25 | 25 | import { persona_description_positions, power_user } from './power-user.js'; |
| 26 | 26 | import { getTokenCountAsync } from './tokenizers.js'; |
| 27 | 27 | import { |
| 28 | 28 | PAGINATION_TEMPLATE, |
| @@ -47,6 +47,8 @@ import { | ||
| 47 | 47 | sortIgnoreCaseAndAccents, |
| 48 | 48 | equalsIgnoreCaseAndAccents, |
| 49 | 49 | uuidv4, |
| 50 | + resolveAvatarData, | |
| 51 | + findPersona, | |
| 50 | 52 | } from './utils.js'; |
| 51 | 53 | import { debounce_timeout } from './constants.js'; |
| 52 | 54 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| @@ -59,10 +61,11 @@ import { saveMetadataDebounced } from './extensions.js'; | ||
| 59 | 61 | import { accountStorage } from './util/AccountStorage.js'; |
| 60 | 62 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 61 | 63 | import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js'; |
| 62 | 64 | import { commonEnumMatchProviders, commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 63 | 65 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 64 | 66 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 65 | 67 | import { isFirefox } from './browser-fixes.js'; |
| 68 | +import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js'; | |
| 66 | 69 | |
| 67 | 70 | /** |
| 68 | 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 | 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 | 99 | const USER_AVATAR_PATH = 'User Avatars/'; |
| 85 | 100 | |
| 86 | 101 | let savePersonasPage = 0; |
| @@ -488,18 +503,28 @@ async function createDummyPersona() { | ||
| 488 | 503 | * @param {string} personaName Name for the persona |
| 489 | 504 | * @param {string} personaDescription Optional description for the persona |
| 490 | 505 | * @param {string} personaTitle Optional title for the persona |
| 491 | 506 | * @param {object} [options={}] Optional settings |
| 492 | 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 | 512 | * @returns {Promise<void>} |
| 494 | 513 | */ |
| 495 | 514 | export async function initPersona(avatarId, personaName, personaDescription, personaTitle, { silent = false } = {}) { |
| 515 | + silent = false, | |
| 516 | + position = persona_description_positions.IN_PROMPT, | |
| 517 | + depth = DEFAULT_DEPTH, | |
| 518 | + role = DEFAULT_ROLE, | |
| 519 | + lorebook = '', | |
| 520 | +} = {}) { | |
| 496 | 521 | power_user.personas[avatarId] = personaName; |
| 497 | 522 | power_user.persona_descriptions[avatarId] = { |
| 498 | 523 | description: personaDescription || '', |
| 499 | 524 | position: persona_description_positions.IN_PROMPTposition, |
| 500 | 525 | depth: DEFAULT_DEPTHdepth, |
| 501 | 526 | role: DEFAULT_ROLErole, |
| 502 | 527 | lorebook: ''lorebook, |
| 503 | 528 | title: personaTitle || '', |
| 504 | 529 | }; |
| 505 | 530 | |
| @@ -773,15 +798,16 @@ export async function askForPersonaSelection(title, text, personas, { okButton = | ||
| 773 | 798 | /** |
| 774 | 799 | * Automatically selects a persona based on the given name if a matching persona exists. |
| 775 | 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 | 803 | * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise |
| 777 | 804 | */ |
| 778 | 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 | 807 | if (value === namepersona) { |
| 781 | 808 | console.log(`Auto-selecting persona ${keypersona.avatar} for name ${name}`); |
| 782 | 809 | await setUserAvatar(keypersona.avatar); |
| 783 | 810 | return true; |
| 784 | - } | |
| 785 | 811 | } |
| 786 | 812 | return false; |
| 787 | 813 | } |
| @@ -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 | 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 | 1150 | if (!avatarId) { |
| 1113 | 1151 | console.warn('No avatar id found'); |
| 1114 | 1152 | return false; |
| 1115 | 1153 | } |
| 1154 | + | |
| 1116 | 1155 | const name = power_user.personas[avatarId] || ''; |
| 1117 | - const confirm = await Popup.show.confirm( | |
| 1118 | - 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.`); | |
| 1120 | 1156 | |
| 1121 | 1157 | if (!confirmsilent) { |
| 1122 | - console.debug('User cancelled deleting avatar'); | |
| 1158 | + const confirm = await Popup.show.confirm( | |
| 1123 | - return; | |
| 1159 | + t`Delete Persona` + `: ${name}`, | |
| 1160 | + t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`); | |
| 1161 | + | |
| 1162 | + if (!confirm) { | |
| 1163 | + console.debug('User cancelled deleting avatar'); | |
| 1164 | + return false; | |
| 1165 | + } | |
| 1124 | 1166 | } |
| 1125 | 1167 | |
| 1126 | 1168 | const request = await fetch('/api/avatars/delete', { |
| @@ -1137,12 +1179,12 @@ async function deleteUserAvatar() { | ||
| 1137 | 1179 | delete power_user.persona_descriptions[avatarId]; |
| 1138 | 1180 | |
| 1139 | 1181 | if (avatarId === power_user.default_persona) { |
| 1140 | 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 | 1183 | power_user.default_persona = null; |
| 1142 | 1184 | } |
| 1143 | 1185 | |
| 1144 | 1186 | if (avatarId === chat_metadata.persona) { |
| 1145 | 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 | 1188 | delete chat_metadata.persona; |
| 1147 | 1189 | await saveMetadata(); |
| 1148 | 1190 | } |
| @@ -1153,7 +1195,10 @@ async function deleteUserAvatar() { | ||
| 1153 | 1195 | // Use the existing mechanism to re-render the persona list and choose the next persona here |
| 1154 | 1196 | personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id |
| 1155 | 1197 | await loadPersonaForCurrentChat({ doRender: true }); |
| 1198 | + return true; | |
| 1156 | 1199 | } |
| 1200 | + | |
| 1201 | + return false; | |
| 1157 | 1202 | } |
| 1158 | 1203 | |
| 1159 | 1204 | async function onPersonaDescriptionInput() { |
| @@ -1839,22 +1884,27 @@ export async function retriggerFirstMessageOnEmptyChat() { | ||
| 1839 | 1884 | |
| 1840 | 1885 | /** |
| 1841 | 1886 | * Duplicates a persona. |
| 1842 | 1887 | * @param {string} avatarId Source persona avatar id |
| 1843 | 1888 | * @returnsparam {Promise<void>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 | 1894 | const personaName = power_user.personas[avatarId]; |
| 1847 | 1895 | |
| 1848 | 1896 | if (!personaName) { |
| 1849 | 1897 | toastr.warning('t`Chosen avatar is not a persona'`, t`Persona Management`); |
| 1850 | 1898 | return ''; |
| 1851 | 1899 | } |
| 1852 | 1900 | |
| 1853 | - const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName); | |
| 1901 | + if (!silent) { | |
| 1902 | + const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName); | |
| 1854 | 1903 | |
| 1855 | 1904 | if (!confirm) { |
| 1856 | 1905 | console.debug('User cancelled duplicating persona'); |
| 1857 | - return; | |
| 1906 | + return ''; | |
| 1907 | + } | |
| 1858 | 1908 | } |
| 1859 | 1909 | |
| 1860 | 1910 | const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`; |
| @@ -1883,6 +1933,12 @@ async function duplicatePersona(avatarId) { | ||
| 1883 | 1933 | |
| 1884 | 1934 | await getUserAvatars(true, newAvatarId); |
| 1885 | 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 | 2365 | * Locks or unlocks the persona of the current chat. |
| 1904 | 2366 | * @param {{type: string}} _args Named arguments |
| @@ -1956,10 +2418,9 @@ async function setNameCallback({ mode = 'all' }, name) { | ||
| 1956 | 2418 | |
| 1957 | 2419 | // If the name matches a persona avatar, or a name, auto-select it |
| 1958 | 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 | 2422 | if (persona) { |
| 1962 | 2423 | await autoSelectPersona(persona.name, { personaKey: persona.avatar }); |
| 1963 | 2424 | return ''; |
| 1964 | 2425 | } else if (mode === 'lookup') { |
| 1965 | 2426 | toastr.warning(`Persona ${name} not found`); |
| @@ -2007,22 +2468,343 @@ function userMessageNamesEnumProvider() { | ||
| 2007 | 2468 | } |
| 2008 | 2469 | |
| 2009 | 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 | 2779 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2011 | 2780 | name: 'persona-lock', |
| 2012 | 2781 | aliases: ['lock', 'bind'], |
| 2013 | 2782 | callback: lockPersonaCallback, |
| 2014 | 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 | 2798 | namedArgumentList: [ |
| 2017 | 2799 | SlashCommandNamedArgument.fromProps({ |
| 2018 | 2800 | name: 'type', |
| 2019 | 2801 | description: 't`The type of the lock, where it should apply to'`, |
| 2020 | 2802 | typeList: [ARGUMENT_TYPE.STRING], |
| 2021 | 2803 | defaultValue: 'chat', |
| 2022 | 2804 | enumList: [ |
| 2023 | 2805 | new SlashCommandEnumValue('chat', 't`Lock the persona to the current chat.'`), |
| 2024 | 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 | 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 | 2820 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2038 | 2821 | name: 'persona-set', |
| 2039 | 2822 | callback: setNameCallback, |
| 2040 | 2823 | aliases: ['persona', 'name'], |
| 2041 | 2824 | namedArgumentList: [ |
| 2042 | 2825 | new 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 | 2837 | unnamedArgumentList: [ |
| 2048 | 2838 | SlashCommandArgument.fromProps({ |
| 2049 | 2839 | description: 'persona name', |
| 2050 | 2840 | typeList: [ARGUMENT_TYPE.STRING], |
| 2051 | 2841 | isRequired: true, |
| 2052 | 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 | 2862 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2058 | 2863 | name: 'persona-sync', |
| 2059 | 2864 | aliases: ['sync'], |
| @@ -68,6 +68,7 @@ import { bindModelTemplates } from './chat-templates.js'; | ||
| 68 | 68 | import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js'; |
| 69 | 69 | import { t } from './i18n.js'; |
| 70 | 70 | import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js'; |
| 71 | +import { persona_description_positions as _persona_description_positions } from './personas.js'; | |
| 71 | 72 | |
| 72 | 73 | export const toastPositionClasses = [ |
| 73 | 74 | 'toast-top-left', |
| @@ -110,17 +111,7 @@ export const send_on_enter_options = { | ||
| 110 | 111 | ENABLED: 1, |
| 111 | 112 | }; |
| 112 | 113 | |
| 113 | 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 | 116 | export const power_user = { |
| 126 | 117 | charListGrid: false, |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { Fuse, DOMPurify } from '../lib.js'; |
| 2 | 2 | import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrlresolveAvatarData } from './utils.js'; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | Generate, |
| @@ -88,7 +88,7 @@ import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortC | ||
| 88 | 88 | import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; |
| 89 | 89 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 90 | 90 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 91 | 91 | import { commonEnumProviders, enumIcons, commonEnumMatchProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 92 | 92 | import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; |
| 93 | 93 | import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js'; |
| 94 | 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 | 771 | // Shared character field definitions for char CRUD commands |
| 789 | 772 | const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [ |
| 790 | 773 | SlashCommandNamedArgument.fromProps({ |
| @@ -873,11 +856,11 @@ export function initDefaultSlashCommands() { | ||
| 873 | 856 | isRequired: requiredFields.includes('avatar'), |
| 874 | 857 | enumList: [ |
| 875 | 858 | new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'), |
| 876 | 859 | new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'), |
| 877 | 860 | new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'), |
| 878 | 861 | new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'), |
| 879 | 862 | new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'), |
| 880 | 863 | new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'), |
| 881 | 864 | ], |
| 882 | 865 | }), |
| 883 | 866 | SlashCommandNamedArgument.fromProps({ |
| @@ -1241,7 +1224,7 @@ export function initDefaultSlashCommands() { | ||
| 1241 | 1224 | modifyAt = chat.length + modifyAt; |
| 1242 | 1225 | } |
| 1243 | 1226 | return chat[modifyAt]?.is_user |
| 1244 | 1227 | ? commonEnumProviders.personas()() |
| 1245 | 1228 | : commonEnumProviders.characters('character')(); |
| 1246 | 1229 | }, |
| 1247 | 1230 | }), |
| @@ -1769,7 +1752,7 @@ export function initDefaultSlashCommands() { | ||
| 1769 | 1752 | description: t`display name`, |
| 1770 | 1753 | typeList: [ARGUMENT_TYPE.STRING], |
| 1771 | 1754 | defaultValue: '{{user}}', |
| 1772 | 1755 | enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }), |
| 1773 | 1756 | }), |
| 1774 | 1757 | SlashCommandNamedArgument.fromProps({ |
| 1775 | 1758 | name: 'return', |
| @@ -5034,23 +5017,6 @@ async function triggerGenerationCallback(args, value) { | ||
| 5034 | 5017 | |
| 5035 | 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 | 5021 | async function sendUserMessageCallback(args, text) { |
| 5056 | 5022 | text = String(text ?? '').trim(); |
| @@ -5068,7 +5034,7 @@ async function sendUserMessageCallback(args, text) { | ||
| 5068 | 5034 | let message; |
| 5069 | 5035 | if ('name' in args) { |
| 5070 | 5036 | const name = args.name || ''; |
| 5071 | 5037 | const avatar = findPersonaByNamefindPersona({ name })?.avatar || user_avatar; |
| 5072 | 5038 | message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar); |
| 5073 | 5039 | } else { |
| 5074 | 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 | 5116 | * Uploads an avatar image to a character. |
| 5250 | 5117 | * @param {string} avatarKey - The character's avatar filename (e.g., "name.png") |
| 5251 | 5118 | * @param {string} base64Data - Base64 data URL of the image |
| @@ -214,9 +214,13 @@ export const commonEnumProviders = { | ||
| 214 | 214 | /** |
| 215 | 215 | * All possible personas |
| 216 | 216 | * |
| 217 | 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 | 226 | * All possible tags, or only those that have been assigned |
| @@ -234,9 +238,9 @@ export const commonEnumProviders = { | ||
| 234 | 238 | * All possible tags for a given char/group entity |
| 235 | 239 | * |
| 236 | 240 | * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show |
| 237 | 241 | * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]} |
| 238 | 242 | */ |
| 239 | 243 | tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor, _scope) => { |
| 240 | 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 | 245 | const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value; |
| 242 | 246 | if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures'); |
| @@ -347,3 +351,28 @@ export const commonEnumProviders = { | ||
| 347 | 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 | 1844 | * An array of all supported image MIME types. |
| 1746 | 1845 | */ |
| 1747 | 1846 | export const supportedImageMimeTypes = Object.freeze([ |