|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { DOMPurify } from '../../../lib.js'; |
|
|
import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js'; |
|
|
import { deleteExtension, extensionNames, getContext, installExtension, renderExtensionTemplateAsync } from '../../extensions.js'; |
|
|
import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; |
|
|
import { executeSlashCommandsWithOptions } from '../../slash-commands.js'; |
|
|
import { accountStorage } from '../../util/AccountStorage.js'; |
|
|
import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; |
|
|
import { t, translate } from '../../i18n.js'; |
|
|
export { MODULE_NAME }; |
|
|
|
|
|
const MODULE_NAME = 'assets'; |
|
|
const DEBUG_PREFIX = '<Assets module> '; |
|
|
let previewAudio = null; |
|
|
let ASSETS_JSON_URL = 'https://raw.githubusercontent.com/SillyTavern/SillyTavern-Content/main/index.json'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let availableAssets = {}; |
|
|
let currentAssets = {}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function filterAssets() { |
|
|
const searchValue = String($('#assets_search').val()).toLowerCase().trim(); |
|
|
const typeValue = String($('#assets_type_select').val()); |
|
|
|
|
|
if (typeValue === '') { |
|
|
$('#assets_menu .assets-list-div').show(); |
|
|
$('#assets_menu .assets-list-div h3').show(); |
|
|
} else { |
|
|
$('#assets_menu .assets-list-div h3').hide(); |
|
|
$('#assets_menu .assets-list-div').hide(); |
|
|
$(`#assets_menu .assets-list-div[data-type="${typeValue}"]`).show(); |
|
|
} |
|
|
|
|
|
if (searchValue === '') { |
|
|
$('#assets_menu .asset-block').show(); |
|
|
} else { |
|
|
$('#assets_menu .asset-block').hide(); |
|
|
$('#assets_menu .asset-block').filter(function () { |
|
|
return $(this).text().toLowerCase().includes(searchValue); |
|
|
}).show(); |
|
|
} |
|
|
} |
|
|
|
|
|
const KNOWN_TYPES = { |
|
|
'extension': t`Extensions`, |
|
|
'character': t`Characters`, |
|
|
'ambient': t`Ambient sounds`, |
|
|
'bgm': t`Background music`, |
|
|
'blip': t`Blip sounds`, |
|
|
}; |
|
|
|
|
|
const EMPTY_AUTHOR = { |
|
|
name: '', |
|
|
url: '', |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getAuthorFromUrl(url) { |
|
|
const result = structuredClone(EMPTY_AUTHOR); |
|
|
|
|
|
try { |
|
|
const parsedUrl = new URL(url); |
|
|
const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0); |
|
|
|
|
|
|
|
|
if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) { |
|
|
result.name = pathSegments[0]; |
|
|
result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`; |
|
|
} |
|
|
} |
|
|
catch (error) { |
|
|
console.debug(DEBUG_PREFIX, 'Error parsing URL:', error); |
|
|
} |
|
|
|
|
|
return result; |
|
|
} |
|
|
|
|
|
async function downloadAssetsList(url) { |
|
|
updateCurrentAssets().then(async function () { |
|
|
fetch(url, { cache: 'no-cache' }) |
|
|
.then(response => response.json()) |
|
|
.then(async function(json) { |
|
|
|
|
|
availableAssets = {}; |
|
|
$('#assets_menu').empty(); |
|
|
|
|
|
console.debug(DEBUG_PREFIX, 'Received assets dictionary', json); |
|
|
|
|
|
for (const i of json) { |
|
|
|
|
|
if (availableAssets[i['type']] === undefined) |
|
|
availableAssets[i['type']] = []; |
|
|
|
|
|
availableAssets[i['type']].push(i); |
|
|
} |
|
|
|
|
|
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets); |
|
|
|
|
|
const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0); |
|
|
|
|
|
$('#assets_type_select').empty(); |
|
|
$('#assets_search').val(''); |
|
|
$('#assets_type_select').append($('<option />', { value: '', text: t`All` })); |
|
|
|
|
|
for (const type of assetTypes) { |
|
|
const text = translate(KNOWN_TYPES[type] || type); |
|
|
const option = $('<option />', { value: type, text: text }); |
|
|
$('#assets_type_select').append(option); |
|
|
} |
|
|
|
|
|
if (assetTypes.includes('extension')) { |
|
|
$('#assets_type_select').val('extension'); |
|
|
} |
|
|
|
|
|
$('#assets_type_select').off('change').on('change', filterAssets); |
|
|
$('#assets_search').off('input').on('input', filterAssets); |
|
|
|
|
|
for (const assetType of assetTypes) { |
|
|
let assetTypeMenu = $('<div />', { id: 'assets_audio_ambient_div', class: 'assets-list-div' }); |
|
|
assetTypeMenu.attr('data-type', assetType); |
|
|
assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide(); |
|
|
|
|
|
if (assetType == 'extension') { |
|
|
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation')); |
|
|
} |
|
|
|
|
|
for (const i in availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) { |
|
|
const asset = availableAssets[assetType][i]; |
|
|
const elemId = `assets_install_${assetType}_${i}`; |
|
|
let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' }); |
|
|
const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>'); |
|
|
element.append(label); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
console.debug(DEBUG_PREFIX, 'Checking asset', asset['id'], asset['url']); |
|
|
|
|
|
const assetInstall = async function () { |
|
|
element.off('click'); |
|
|
label.removeClass('fa-download'); |
|
|
this.classList.add('asset-download-button-loading'); |
|
|
await installAsset(asset['url'], assetType, asset['id']); |
|
|
label.addClass('fa-check'); |
|
|
this.classList.remove('asset-download-button-loading'); |
|
|
element.on('click', assetDelete); |
|
|
element.on('mouseenter', function () { |
|
|
label.removeClass('fa-check'); |
|
|
label.addClass('fa-trash'); |
|
|
label.addClass('redOverlayGlow'); |
|
|
}).on('mouseleave', function () { |
|
|
label.addClass('fa-check'); |
|
|
label.removeClass('fa-trash'); |
|
|
label.removeClass('redOverlayGlow'); |
|
|
}); |
|
|
}; |
|
|
|
|
|
const assetDelete = async function () { |
|
|
if (assetType === 'character') { |
|
|
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported'); |
|
|
await executeSlashCommandsWithOptions(`/go ${asset['id']}`); |
|
|
return; |
|
|
} |
|
|
element.off('click'); |
|
|
await deleteAsset(assetType, asset['id']); |
|
|
label.removeClass('fa-check'); |
|
|
label.removeClass('redOverlayGlow'); |
|
|
label.removeClass('fa-trash'); |
|
|
label.addClass('fa-download'); |
|
|
element.off('mouseenter').off('mouseleave'); |
|
|
element.on('click', assetInstall); |
|
|
}; |
|
|
|
|
|
if (isAssetInstalled(assetType, asset['id'])) { |
|
|
console.debug(DEBUG_PREFIX, 'installed, checked'); |
|
|
label.toggleClass('fa-download'); |
|
|
label.toggleClass('fa-check'); |
|
|
element.on('click', assetDelete); |
|
|
element.on('mouseenter', function () { |
|
|
label.removeClass('fa-check'); |
|
|
label.addClass('fa-trash'); |
|
|
label.addClass('redOverlayGlow'); |
|
|
}).on('mouseleave', function () { |
|
|
label.addClass('fa-check'); |
|
|
label.removeClass('fa-trash'); |
|
|
label.removeClass('redOverlayGlow'); |
|
|
}); |
|
|
} |
|
|
else { |
|
|
console.debug(DEBUG_PREFIX, 'not installed, unchecked'); |
|
|
element.prop('checked', false); |
|
|
element.on('click', assetInstall); |
|
|
} |
|
|
|
|
|
console.debug(DEBUG_PREFIX, 'Created element for ', asset['id']); |
|
|
|
|
|
const displayName = DOMPurify.sanitize(asset['name'] || asset['id']); |
|
|
const description = DOMPurify.sanitize(asset['description'] || ''); |
|
|
const url = isValidUrl(asset['url']) ? asset['url'] : ''; |
|
|
const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`; |
|
|
const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple'; |
|
|
const toolTag = assetType === 'extension' && asset['tool']; |
|
|
const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR; |
|
|
|
|
|
const assetBlock = $('<i></i>') |
|
|
.append(element) |
|
|
.append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden"> |
|
|
<span class="asset-name flex-container alignitemscenter"> |
|
|
<b>${displayName}</b> |
|
|
<a class="asset_preview" href="${url}" target="_blank" title="${title}"> |
|
|
<i class="fa-solid fa-sm ${previewIcon}"></i> |
|
|
</a>` + |
|
|
(toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' + |
|
|
t`Tool` + '</span>' : '') + |
|
|
'<span class="expander"></span>' + |
|
|
(author.name ? `<a href="${author.url}" target="_blank" class="asset-author-info"><i class="fa-solid fa-at fa-xs"></i><span>${author.name}</span></a>` : '') + |
|
|
`</span> |
|
|
<small class="asset-description"> |
|
|
${description} |
|
|
</small> |
|
|
</div>`); |
|
|
|
|
|
assetBlock.find('.tag').on('click', function (e) { |
|
|
const a = document.createElement('a'); |
|
|
a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/'; |
|
|
a.target = '_blank'; |
|
|
a.click(); |
|
|
}); |
|
|
|
|
|
if (assetType === 'character') { |
|
|
if (asset.highlight) { |
|
|
assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>'); |
|
|
} |
|
|
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset['url']}" alt="${displayName}"></div>`); |
|
|
} |
|
|
|
|
|
assetBlock.addClass('asset-block'); |
|
|
|
|
|
assetTypeMenu.append(assetBlock); |
|
|
} |
|
|
assetTypeMenu.appendTo('#assets_menu'); |
|
|
assetTypeMenu.on('click', 'a.asset_preview', previewAsset); |
|
|
} |
|
|
|
|
|
filterAssets(); |
|
|
$('#assets_filters').show(); |
|
|
$('#assets_menu').show(); |
|
|
}) |
|
|
.catch((error) => { |
|
|
|
|
|
const installButton = $('#third_party_extension_button'); |
|
|
flashHighlight(installButton, 10_000); |
|
|
toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 }); |
|
|
|
|
|
|
|
|
console.error(error); |
|
|
toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list'); |
|
|
$('#assets-connect-button').addClass('fa-plug-circle-exclamation'); |
|
|
$('#assets-connect-button').addClass('redOverlayGlow'); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
function previewAsset(e) { |
|
|
const href = $(this).attr('href'); |
|
|
const audioExtensions = ['.mp3', '.ogg', '.wav']; |
|
|
|
|
|
if (audioExtensions.some(ext => href.endsWith(ext))) { |
|
|
e.preventDefault(); |
|
|
|
|
|
if (previewAudio) { |
|
|
previewAudio.pause(); |
|
|
|
|
|
if (previewAudio.src === href) { |
|
|
previewAudio = null; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
previewAudio = new Audio(href); |
|
|
previewAudio.play(); |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
function isAssetInstalled(assetType, filename) { |
|
|
let assetList = currentAssets[assetType]; |
|
|
|
|
|
if (assetType == 'extension') { |
|
|
const thirdPartyMarker = 'third-party/'; |
|
|
assetList = extensionNames.filter(x => x.startsWith(thirdPartyMarker)).map(x => x.replace(thirdPartyMarker, '')); |
|
|
} |
|
|
|
|
|
if (assetType == 'character') { |
|
|
assetList = getContext().characters.map(x => x.avatar); |
|
|
} |
|
|
|
|
|
for (const i of assetList) { |
|
|
|
|
|
if (i.includes(filename)) |
|
|
return true; |
|
|
} |
|
|
|
|
|
return false; |
|
|
} |
|
|
|
|
|
async function installAsset(url, assetType, filename) { |
|
|
console.debug(DEBUG_PREFIX, 'Downloading ', url); |
|
|
const category = assetType; |
|
|
try { |
|
|
if (category === 'extension') { |
|
|
console.debug(DEBUG_PREFIX, 'Installing extension ', url); |
|
|
await installExtension(url, false); |
|
|
console.debug(DEBUG_PREFIX, 'Extension installed.'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const body = { url, category, filename }; |
|
|
const result = await fetch('/api/assets/download', { |
|
|
method: 'POST', |
|
|
headers: getRequestHeaders(), |
|
|
body: JSON.stringify(body), |
|
|
cache: 'no-cache', |
|
|
}); |
|
|
if (result.ok) { |
|
|
console.debug(DEBUG_PREFIX, 'Download success.'); |
|
|
if (category === 'character') { |
|
|
console.debug(DEBUG_PREFIX, 'Importing character ', filename); |
|
|
const blob = await result.blob(); |
|
|
const file = new File([blob], filename, { type: blob.type }); |
|
|
await processDroppedFiles([file]); |
|
|
console.debug(DEBUG_PREFIX, 'Character downloaded.'); |
|
|
} |
|
|
} |
|
|
} |
|
|
catch (err) { |
|
|
console.log(err); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
async function deleteAsset(assetType, filename) { |
|
|
console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename); |
|
|
const category = assetType; |
|
|
try { |
|
|
if (category === 'extension') { |
|
|
console.debug(DEBUG_PREFIX, 'Deleting extension ', filename); |
|
|
await deleteExtension(filename); |
|
|
console.debug(DEBUG_PREFIX, 'Extension deleted.'); |
|
|
} |
|
|
|
|
|
const body = { category, filename }; |
|
|
const result = await fetch('/api/assets/delete', { |
|
|
method: 'POST', |
|
|
headers: getRequestHeaders(), |
|
|
body: JSON.stringify(body), |
|
|
cache: 'no-cache', |
|
|
}); |
|
|
if (result.ok) { |
|
|
console.debug(DEBUG_PREFIX, 'Deletion success.'); |
|
|
} |
|
|
} |
|
|
catch (err) { |
|
|
console.log(err); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
async function openCharacterBrowser(forceDefault) { |
|
|
const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val()); |
|
|
const fetchResult = await fetch(url, { cache: 'no-cache' }); |
|
|
const json = await fetchResult.json(); |
|
|
const characters = json.filter(x => x.type === 'character'); |
|
|
|
|
|
if (!characters.length) { |
|
|
toastr.error('No characters found in the assets list', 'Character browser'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'market', {})); |
|
|
|
|
|
for (const character of characters.sort((a, b) => a.name.localeCompare(b.name))) { |
|
|
const listElement = template.find(character.highlight ? '.contestWinnersList' : '.featuredCharactersList'); |
|
|
const characterElement = $(await renderExtensionTemplateAsync(MODULE_NAME, 'character', character)); |
|
|
const downloadButton = characterElement.find('.characterAssetDownloadButton'); |
|
|
const checkMark = characterElement.find('.characterAssetCheckMark'); |
|
|
const isInstalled = isAssetInstalled('character', character.id); |
|
|
|
|
|
downloadButton.toggle(!isInstalled).on('click', async () => { |
|
|
downloadButton.toggleClass('fa-download fa-spinner fa-spin'); |
|
|
await installAsset(character.url, 'character', character.id); |
|
|
downloadButton.hide(); |
|
|
checkMark.show(); |
|
|
}); |
|
|
|
|
|
checkMark.toggle(isInstalled); |
|
|
|
|
|
listElement.append(characterElement); |
|
|
} |
|
|
|
|
|
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false }); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function updateCurrentAssets() { |
|
|
console.debug(DEBUG_PREFIX, 'Checking installed assets...'); |
|
|
try { |
|
|
const result = await fetch('/api/assets/get', { |
|
|
method: 'POST', |
|
|
headers: getRequestHeaders(), |
|
|
}); |
|
|
currentAssets = result.ok ? (await result.json()) : {}; |
|
|
} |
|
|
catch (err) { |
|
|
console.log(err); |
|
|
} |
|
|
console.debug(DEBUG_PREFIX, 'Current assets found:', currentAssets); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
jQuery(async () => { |
|
|
|
|
|
const windowTemplate = await renderExtensionTemplateAsync(MODULE_NAME, 'window', {}); |
|
|
const windowHtml = $(windowTemplate); |
|
|
|
|
|
const assetsJsonUrl = windowHtml.find('#assets-json-url-field'); |
|
|
assetsJsonUrl.val(ASSETS_JSON_URL); |
|
|
|
|
|
const charactersButton = windowHtml.find('#assets-characters-button'); |
|
|
charactersButton.on('click', async function () { |
|
|
openCharacterBrowser(false); |
|
|
}); |
|
|
|
|
|
const installHintButton = windowHtml.find('.assets-install-hint-link'); |
|
|
installHintButton.on('click', async function () { |
|
|
const installButton = $('#third_party_extension_button'); |
|
|
flashHighlight(installButton, 5000); |
|
|
toastr.info(t`Click the flashing button to install extensions.`, t`How to install extensions?`); |
|
|
}); |
|
|
|
|
|
const connectButton = windowHtml.find('#assets-connect-button'); |
|
|
connectButton.on('click', async function () { |
|
|
const url = DOMPurify.sanitize(String(assetsJsonUrl.val())); |
|
|
const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`; |
|
|
const skipConfirm = accountStorage.getItem(rememberKey) === 'true'; |
|
|
|
|
|
const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${url}</var>`, { |
|
|
customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }], |
|
|
onClose: popup => { |
|
|
if (popup.result) { |
|
|
const rememberValue = popup.inputResults.get('assets-remember'); |
|
|
accountStorage.setItem(rememberKey, String(rememberValue)); |
|
|
} |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (confirmation) { |
|
|
try { |
|
|
console.debug(DEBUG_PREFIX, 'Confimation, loading assets...'); |
|
|
downloadAssetsList(url); |
|
|
connectButton.removeClass('fa-plug-circle-exclamation'); |
|
|
connectButton.removeClass('redOverlayGlow'); |
|
|
connectButton.addClass('fa-plug-circle-check'); |
|
|
} catch (error) { |
|
|
console.error('Error:', error); |
|
|
toastr.error(`Cannot get assets list from ${url}`); |
|
|
connectButton.removeClass('fa-plug-circle-check'); |
|
|
connectButton.addClass('fa-plug-circle-exclamation'); |
|
|
connectButton.removeClass('redOverlayGlow'); |
|
|
} |
|
|
} |
|
|
else { |
|
|
console.debug(DEBUG_PREFIX, 'Connection refused by user'); |
|
|
} |
|
|
}); |
|
|
|
|
|
windowHtml.find('#assets_filters').hide(); |
|
|
$('#assets_container').append(windowHtml); |
|
|
|
|
|
eventSource.on(event_types.OPEN_CHARACTER_LIBRARY, async (forceDefault) => { |
|
|
openCharacterBrowser(forceDefault); |
|
|
}); |
|
|
}); |
|
|
|