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()`
This commit is contained in:
+855
-50
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@ import { bindModelTemplates } from './chat-templates.js';
|
|||||||
import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js';
|
import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js';
|
import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js';
|
||||||
|
import { persona_description_positions as _persona_description_positions } from './personas.js';
|
||||||
|
|
||||||
export const toastPositionClasses = [
|
export const toastPositionClasses = [
|
||||||
'toast-top-left',
|
'toast-top-left',
|
||||||
@@ -110,17 +111,7 @@ export const send_on_enter_options = {
|
|||||||
ENABLED: 1,
|
ENABLED: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const persona_description_positions = {
|
export const persona_description_positions = _persona_description_positions;
|
||||||
IN_PROMPT: 0,
|
|
||||||
/**
|
|
||||||
* @deprecated Use persona_description_positions.IN_PROMPT instead.
|
|
||||||
*/
|
|
||||||
AFTER_CHAR: 1,
|
|
||||||
TOP_AN: 2,
|
|
||||||
BOTTOM_AN: 3,
|
|
||||||
AT_DEPTH: 4,
|
|
||||||
NONE: 9,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const power_user = {
|
export const power_user = {
|
||||||
charListGrid: false,
|
charListGrid: false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fuse, DOMPurify } from '../lib.js';
|
import { Fuse, DOMPurify } from '../lib.js';
|
||||||
import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js';
|
import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, resolveAvatarData } from './utils.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Generate,
|
Generate,
|
||||||
@@ -88,7 +88,7 @@ import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortC
|
|||||||
import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
|
import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
|
||||||
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
|
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
|
||||||
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
|
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
|
||||||
import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
|
import { commonEnumProviders, enumIcons, commonEnumMatchProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||||
import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
|
import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
|
||||||
import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
|
import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
|
||||||
import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
|
import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
|
||||||
@@ -768,23 +768,6 @@ export function initDefaultSlashCommands() {
|
|||||||
`,
|
`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides autocomplete matching for folder names.
|
|
||||||
* Matches if the input starts with the check or vice versa (case-insensitive).
|
|
||||||
* @param {string} input - The input string to match against
|
|
||||||
* @param {string} check - The check string to match with
|
|
||||||
* @param {object} [options={}] - Options
|
|
||||||
* @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty
|
|
||||||
* @returns {boolean} - True if the strings match according to the folder matching rules
|
|
||||||
*/
|
|
||||||
function folderEnumMatchProvider(input, check, { trueOnEmpty = true } = {}) {
|
|
||||||
if (!check) return false;
|
|
||||||
if (!input) return trueOnEmpty;
|
|
||||||
const inputLower = input.toLowerCase();
|
|
||||||
const checkLower = check.toLowerCase();
|
|
||||||
return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared character field definitions for char CRUD commands
|
// Shared character field definitions for char CRUD commands
|
||||||
const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [
|
const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [
|
||||||
SlashCommandNamedArgument.fromProps({
|
SlashCommandNamedArgument.fromProps({
|
||||||
@@ -873,11 +856,11 @@ export function initDefaultSlashCommands() {
|
|||||||
isRequired: requiredFields.includes('avatar'),
|
isRequired: requiredFields.includes('avatar'),
|
||||||
enumList: [
|
enumList: [
|
||||||
new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'),
|
new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'),
|
||||||
new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'characters/'), () => 'characters/'),
|
new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'),
|
||||||
new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'backgrounds/'), () => 'backgrounds/'),
|
new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'),
|
||||||
new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'User Avatars/'), () => 'User Avatars/'),
|
new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'),
|
||||||
new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'assets/'), () => 'assets/'),
|
new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'),
|
||||||
new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'user/images/'), () => 'user/images/'),
|
new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
SlashCommandNamedArgument.fromProps({
|
SlashCommandNamedArgument.fromProps({
|
||||||
@@ -1241,7 +1224,7 @@ export function initDefaultSlashCommands() {
|
|||||||
modifyAt = chat.length + modifyAt;
|
modifyAt = chat.length + modifyAt;
|
||||||
}
|
}
|
||||||
return chat[modifyAt]?.is_user
|
return chat[modifyAt]?.is_user
|
||||||
? commonEnumProviders.personas()
|
? commonEnumProviders.personas()()
|
||||||
: commonEnumProviders.characters('character')();
|
: commonEnumProviders.characters('character')();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1769,7 +1752,7 @@ export function initDefaultSlashCommands() {
|
|||||||
description: t`display name`,
|
description: t`display name`,
|
||||||
typeList: [ARGUMENT_TYPE.STRING],
|
typeList: [ARGUMENT_TYPE.STRING],
|
||||||
defaultValue: '{{user}}',
|
defaultValue: '{{user}}',
|
||||||
enumProvider: commonEnumProviders.personas,
|
enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
|
||||||
}),
|
}),
|
||||||
SlashCommandNamedArgument.fromProps({
|
SlashCommandNamedArgument.fromProps({
|
||||||
name: 'return',
|
name: 'return',
|
||||||
@@ -5034,23 +5017,6 @@ async function triggerGenerationCallback(args, value) {
|
|||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Find persona by name.
|
|
||||||
* @param {string} name Name to search for
|
|
||||||
* @returns {string} Persona name
|
|
||||||
*/
|
|
||||||
function findPersonaByName(name) {
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const persona of Object.entries(power_user.personas)) {
|
|
||||||
if (equalsIgnoreCaseAndAccents(persona[1], name)) {
|
|
||||||
return persona[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendUserMessageCallback(args, text) {
|
async function sendUserMessageCallback(args, text) {
|
||||||
text = String(text ?? '').trim();
|
text = String(text ?? '').trim();
|
||||||
@@ -5068,7 +5034,7 @@ async function sendUserMessageCallback(args, text) {
|
|||||||
let message;
|
let message;
|
||||||
if ('name' in args) {
|
if ('name' in args) {
|
||||||
const name = args.name || '';
|
const name = args.name || '';
|
||||||
const avatar = findPersonaByName(name) || user_avatar;
|
const avatar = findPersona({ name })?.avatar || user_avatar;
|
||||||
message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);
|
message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);
|
||||||
} else {
|
} else {
|
||||||
message = await sendMessageAsUser(text, bias, insertAt, compact);
|
message = await sendMessageAsUser(text, bias, insertAt, compact);
|
||||||
@@ -5146,105 +5112,6 @@ async function openChat(chid) {
|
|||||||
await reloadCurrentChat();
|
await reloadCurrentChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a file picker dialog for selecting an image.
|
|
||||||
* @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
|
|
||||||
*/
|
|
||||||
async function promptForAvatarFile() {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = supportedImageMimeTypes.join(',');
|
|
||||||
input.onchange = async (e) => {
|
|
||||||
if (!(e.target instanceof HTMLInputElement)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const file = e.target?.files?.[0];
|
|
||||||
if (!file) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const converted = await ensureImageFormatSupported(file);
|
|
||||||
const base64 = await getBase64Async(converted);
|
|
||||||
resolve(base64);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing selected image:', error);
|
|
||||||
toastr.error(t`Failed to process selected image: ${error.message}`);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.oncancel = () => resolve(null);
|
|
||||||
input.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves avatar data from various input formats (base64, local path, or prompt).
|
|
||||||
* @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
|
|
||||||
* @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
|
|
||||||
*/
|
|
||||||
async function resolveAvatarData(input) {
|
|
||||||
if (!input || typeof input !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = input.trim();
|
|
||||||
|
|
||||||
// Special value "prompt" opens file picker
|
|
||||||
if (trimmed.toLowerCase() === 'prompt') {
|
|
||||||
return await promptForAvatarFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already a base64 data URL
|
|
||||||
if (trimmed.startsWith('data:image/')) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// External URLs are not supported
|
|
||||||
if (isExternalUrl(trimmed)) {
|
|
||||||
toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
|
|
||||||
// Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
|
|
||||||
// Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
|
|
||||||
if (trimmed.includes('/') || trimmed.endsWith('.png')) {
|
|
||||||
try {
|
|
||||||
// Construct the URL to fetch the local file
|
|
||||||
let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
||||||
// Handle same-origin URLs
|
|
||||||
if (trimmed.startsWith(window.location.origin)) {
|
|
||||||
url = new URL(trimmed).pathname;
|
|
||||||
}
|
|
||||||
// If there is no subfolder, we guess this should be a character image
|
|
||||||
if (!url.includes('/', 1)) {
|
|
||||||
url = '/characters/' + trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`File not found or inaccessible: ${response.status}`);
|
|
||||||
}
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (!blob.type.startsWith('image/')) {
|
|
||||||
throw new Error('File is not an image');
|
|
||||||
}
|
|
||||||
const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
|
|
||||||
return await getBase64Async(converted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching local avatar:', error);
|
|
||||||
toastr.warning(t`Failed to load avatar from path: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown format
|
|
||||||
console.warn('Unknown avatar format:', trimmed.substring(0, 50));
|
|
||||||
toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads an avatar image to a character.
|
* Uploads an avatar image to a character.
|
||||||
* @param {string} avatarKey - The character's avatar filename (e.g., "name.png")
|
* @param {string} avatarKey - The character's avatar filename (e.g., "name.png")
|
||||||
|
|||||||
@@ -214,9 +214,13 @@ export const commonEnumProviders = {
|
|||||||
/**
|
/**
|
||||||
* All possible personas
|
* All possible personas
|
||||||
*
|
*
|
||||||
* @returns {SlashCommandEnumValue[]}
|
* @returns {() => SlashCommandEnumValue[]}
|
||||||
*/
|
*/
|
||||||
personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
|
personas: ({ allowPersonaKey = false } = {}) => () => Object.entries(power_user.personas).map(([personaKey, personaName]) => {
|
||||||
|
const existsMultiple = Object.values(power_user.personas).filter(p => p === personaName).length > 1;
|
||||||
|
const returnValue = allowPersonaKey && existsMultiple ? personaKey : personaName;
|
||||||
|
return new SlashCommandEnumValue(returnValue, allowPersonaKey && existsMultiple ? personaName : null, enumTypes.name, enumIcons.persona);
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All possible tags, or only those that have been assigned
|
* All possible tags, or only those that have been assigned
|
||||||
@@ -234,9 +238,9 @@ export const commonEnumProviders = {
|
|||||||
* All possible tags for a given char/group entity
|
* All possible tags for a given char/group entity
|
||||||
*
|
*
|
||||||
* @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
|
* @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
|
||||||
* @returns {() => SlashCommandEnumValue[]}
|
* @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
|
||||||
*/
|
*/
|
||||||
tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor) => {
|
tagsForChar: (mode = 'all') => (executor, _scope) => {
|
||||||
// Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.
|
// Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.
|
||||||
const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;
|
const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;
|
||||||
if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
|
if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
|
||||||
@@ -347,3 +351,28 @@ export const commonEnumProviders = {
|
|||||||
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
|
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collection of common enum match providers
|
||||||
|
*
|
||||||
|
* Can be used on `SlashCommandEnumValue` and their `matchProvider` property.
|
||||||
|
*/
|
||||||
|
export const commonEnumMatchProviders = {
|
||||||
|
/**
|
||||||
|
* Provides autocomplete matching for folder-like enum values.
|
||||||
|
* Matches if the input starts with the check or vice versa (case-insensitive).
|
||||||
|
* @param {string} input - The input string to match against
|
||||||
|
* @param {string} check - The check string to match with
|
||||||
|
* @param {object} [options={}] - Options
|
||||||
|
* @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty
|
||||||
|
* @returns {boolean} - True if the strings match according to the folder matching rules
|
||||||
|
*/
|
||||||
|
folderEnum: (input, check, { trueOnEmpty = true } = {}) => {
|
||||||
|
if (!check) return false;
|
||||||
|
if (!input) return trueOnEmpty;
|
||||||
|
const inputLower = input.toLowerCase();
|
||||||
|
const checkLower = check.toLowerCase();
|
||||||
|
return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1741,6 +1741,105 @@ export function loadFileToDocument(url, type) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a file picker dialog for selecting an image.
|
||||||
|
* @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
|
||||||
|
*/
|
||||||
|
export async function promptForAvatarFile() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = supportedImageMimeTypes.join(',');
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
if (!(e.target instanceof HTMLInputElement)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const file = e.target?.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const converted = await ensureImageFormatSupported(file);
|
||||||
|
const base64 = await getBase64Async(converted);
|
||||||
|
resolve(base64);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing selected image:', error);
|
||||||
|
toastr.error(t`Failed to process selected image: ${error.message}`);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.oncancel = () => resolve(null);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves avatar data from various input formats (base64, local path, or prompt).
|
||||||
|
* @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
|
||||||
|
* @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
|
||||||
|
*/
|
||||||
|
export async function resolveAvatarData(input) {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Special value "prompt" opens file picker
|
||||||
|
if (trimmed.toLowerCase() === 'prompt') {
|
||||||
|
return await promptForAvatarFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already a base64 data URL
|
||||||
|
if (trimmed.startsWith('data:image/')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// External URLs are not supported
|
||||||
|
if (isExternalUrl(trimmed)) {
|
||||||
|
toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
|
||||||
|
// Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
|
||||||
|
// Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
|
||||||
|
if (trimmed.includes('/') || trimmed.endsWith('.png')) {
|
||||||
|
try {
|
||||||
|
// Construct the URL to fetch the local file
|
||||||
|
let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||||
|
// Handle same-origin URLs
|
||||||
|
if (trimmed.startsWith(window.location.origin)) {
|
||||||
|
url = new URL(trimmed).pathname;
|
||||||
|
}
|
||||||
|
// If there is no subfolder, we guess this should be a character image
|
||||||
|
if (!url.includes('/', 1)) {
|
||||||
|
url = '/characters/' + trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`File not found or inaccessible: ${response.status}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (!blob.type.startsWith('image/')) {
|
||||||
|
throw new Error('File is not an image');
|
||||||
|
}
|
||||||
|
const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
|
||||||
|
return await getBase64Async(converted);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching local avatar:', error);
|
||||||
|
toastr.warning(t`Failed to load avatar from path: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown format
|
||||||
|
console.warn('Unknown avatar format:', trimmed.substring(0, 50));
|
||||||
|
toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An array of all supported image MIME types.
|
* An array of all supported image MIME types.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user