Blame Raw
Cohee · 51ad27fb · · 598 lines (23.6 KB)
2 contributors
1/*
2TODO:
3*/
4//const DEBUG_TONY_SAMA_FORK_MODE = true
5
6import { DOMPurify } from '../../../lib.js';
7import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
8import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
9import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
10import { accountStorage } from '../../util/AccountStorage.js';
11import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
12import { t, translate } from '../../i18n.js';
13import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js';
14export { MODULE_NAME };
15
16const MODULE_NAME = 'assets';
17const DEBUG_PREFIX = '<Assets module> ';
18let previewAudio = null;
19let ASSETS_JSON_URL = 'https://raw.githubusercontent.com/SillyTavern/SillyTavern-Content/main/index.json';
20
21
22// DBG
23//if (DEBUG_TONY_SAMA_FORK_MODE)
24// ASSETS_JSON_URL = "https://raw.githubusercontent.com/Tony-sama/SillyTavern-Content/main/index.json"
25let availableAssets = {};
26let currentAssets = {};
27
28//#############################//
29// Extension UI and Settings //
30//#############################//
31
32function filterAssets() {
33 const searchValue = String($('#assets_search').val()).toLowerCase().trim();
34 const typeValue = String($('#assets_type_select').val());
35
36 if (typeValue === '') {
37 $('#assets_menu .assets-list-div').show();
38 $('#assets_menu .assets-list-div h3').show();
39 } else {
40 $('#assets_menu .assets-list-div h3').hide();
41 $('#assets_menu .assets-list-div').hide();
42 $(`#assets_menu .assets-list-div[data-type="${typeValue}"]`).show();
43 }
44
45 if (searchValue === '') {
46 $('#assets_menu .asset-block').show();
47 } else {
48 $('#assets_menu .asset-block').hide();
49 $('#assets_menu .asset-block').filter(function () {
50 return $(this).text().toLowerCase().includes(searchValue);
51 }).show();
52 }
53}
54
55const KNOWN_TYPES = {
56 'extension': t`Extensions`,
57 'character': t`Characters`,
58 'ambient': t`Ambient sounds`,
59 'bgm': t`Background music`,
60 'blip': t`Blip sounds`,
61};
62
63/**
64 * Creates the download/delete button element for a single asset, with all interaction handlers attached.
65 * @param {object} asset The asset data object, containing at least id, name, description and url fields
66 * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
67 * @param {number} index Index of the asset in the list of available assets of the same type, used to create a unique element ID
68 * @returns {JQuery} The button element
69 */
70function createAssetButton(asset, assetType, index) {
71 const elemId = `assets_install_${assetType}_${index}`;
72 const element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
73 const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>');
74 element.append(label);
75
76 console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url);
77
78 const assetInstall = async function () {
79 element.off('click');
80 label.removeClass('fa-download');
81 this.classList.add('asset-download-button-loading');
82 const result = await installAsset(asset.url, assetType, asset.id);
83 if (!result) {
84 this.classList.remove('asset-download-button-loading');
85 label.addClass('fa-download');
86 label.removeClass('fa-spinner');
87 label.removeClass('fa-spin');
88 element.on('click', assetInstall);
89 return;
90 }
91 label.addClass('fa-check');
92 this.classList.remove('asset-download-button-loading');
93 element.on('click', assetDelete);
94 element.on('mouseenter', function () {
95 label.removeClass('fa-check');
96 label.addClass('fa-trash');
97 label.addClass('redOverlayGlow');
98 }).on('mouseleave', function () {
99 label.addClass('fa-check');
100 label.removeClass('fa-trash');
101 label.removeClass('redOverlayGlow');
102 });
103 };
104
105 const assetDelete = async function () {
106 if (assetType === 'character') {
107 toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
108 await SlashCommandParser.commands.go.callback(null, asset.id);
109 return;
110 }
111 element.off('click');
112 await deleteAsset(assetType, asset.id);
113 label.removeClass('fa-check');
114 label.removeClass('redOverlayGlow');
115 label.removeClass('fa-trash');
116 label.addClass('fa-download');
117 element.off('mouseenter').off('mouseleave');
118 element.on('click', assetInstall);
119 };
120
121 if (isAssetInstalled(assetType, asset.id)) {
122 console.debug(DEBUG_PREFIX, 'installed, checked');
123 label.toggleClass('fa-download');
124 label.toggleClass('fa-check');
125 element.on('click', assetDelete);
126 element.on('mouseenter', function () {
127 label.removeClass('fa-check');
128 label.addClass('fa-trash');
129 label.addClass('redOverlayGlow');
130 }).on('mouseleave', function () {
131 label.addClass('fa-check');
132 label.removeClass('fa-trash');
133 label.removeClass('redOverlayGlow');
134 });
135 } else {
136 console.debug(DEBUG_PREFIX, 'not installed, unchecked');
137 element.prop('checked', false);
138 element.on('click', assetInstall);
139 }
140
141 return element;
142}
143
144/**
145 * Creates the full visual block element for a single asset.
146 * @param {object} asset The asset data object, containing at least id, name, description and url fields
147 * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
148 * @param {JQuery} element The button element from createAssetButton
149 * @returns {JQuery} The asset block element
150 */
151function createAssetBlock(asset, assetType, element) {
152 console.debug(DEBUG_PREFIX, 'Created element for ', asset.id);
153
154 const displayName = DOMPurify.sanitize(asset.name || asset.id);
155 const description = DOMPurify.sanitize(asset.description || '');
156 const url = isValidUrl(asset.url) ? asset.url : '';
157 const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
158 const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
159 const toolTag = assetType === 'extension' && asset.tool;
160 const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
161
162 const nameSpan = $('<span>', { class: 'asset-name flex-container alignitemscenter' })
163 .append($('<b>').text(displayName))
164 .append($('<a>', { class: 'asset_preview', href: url, target: '_blank', title: title })
165 .append($('<i>', { class: `fa-solid fa-sm ${previewIcon}` })));
166
167 if (toolTag) {
168 const tagSpan = $('<span>', { class: 'tag', title: t`Adds a function tool` })
169 .append($('<i>', { class: 'fa-solid fa-sm fa-wrench' }))
170 .append(document.createTextNode(` ${t`Tool`}`));
171 nameSpan.append(tagSpan);
172 }
173
174 nameSpan.append($('<span>', { class: 'expander' }));
175
176 if (author.name) {
177 nameSpan.append($('<a>', { href: author.url, target: '_blank', class: 'asset-author-info' })
178 .append($('<i>', { class: 'fa-solid fa-at fa-xs' }))
179 .append($('<span>').text(author.name)));
180 }
181
182 const infoDiv = $('<div>', { class: 'flex-container flexFlowColumn flexNoGap wide100p overflowHidden' })
183 .append(nameSpan)
184 .append($('<small>', { class: 'asset-description' }).text(description));
185
186 const assetBlock = $('<i></i>').append(element).append(infoDiv);
187
188 assetBlock.find('.tag').on('click', function (e) {
189 const a = document.createElement('a');
190 a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/';
191 a.target = '_blank';
192 a.click();
193 });
194
195 if (assetType === 'character') {
196 if (asset.highlight) {
197 nameSpan.append($('<i>', { class: 'fa-solid fa-sm fa-trophy' }));
198 }
199 nameSpan.prepend($('<div>', { class: 'avatar' }).append($('<img>', { src: asset.url, alt: displayName })));
200 }
201
202 assetBlock.addClass('asset-block');
203 return assetBlock;
204}
205
206/**
207 * Builds and appends the menu section for a single asset type.
208 * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
209 * @returns {Promise<void>}
210 */
211async function buildAssetTypeSection(assetType) {
212 const assetTypeMenu = $('<div />', { id: `assets_${assetType}_div`, class: 'assets-list-div' });
213 assetTypeMenu.attr('data-type', assetType);
214 assetTypeMenu.append($('<h3>').text(KNOWN_TYPES[assetType] || assetType)).hide();
215
216 if (assetType == 'extension') {
217 assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
218 }
219
220 for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
221 const i = availableAssets[assetType].indexOf(asset);
222 const element = createAssetButton(asset, assetType, i);
223 const assetBlock = createAssetBlock(asset, assetType, element);
224
225 if (assetType === 'extension') {
226 const extensionBlockList = isOfficialExtension(asset.url)
227 ? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions')
228 : assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions');
229 extensionBlockList.append(assetBlock);
230 } else {
231 assetTypeMenu.append(assetBlock);
232 }
233 }
234
235 assetTypeMenu.appendTo('#assets_menu');
236 assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
237}
238
239/**
240 * Parses the fetched assets JSON and renders the full assets menu.
241 * @param {object[]} json Array of asset objects, each containing at least id, name, description, url and type fields
242 */
243async function populateAssetsMenu(json) {
244 availableAssets = {};
245 $('#assets_menu').empty();
246
247 console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
248
249 for (const i of json) {
250 if (availableAssets[i.type] === undefined)
251 availableAssets[i.type] = [];
252 availableAssets[i.type].push(i);
253 }
254
255 console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
256 // First extensions, then everything else
257 const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
258
259 $('#assets_type_select').empty();
260 $('#assets_search').val('');
261 $('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
262
263 for (const type of assetTypes) {
264 const text = translate(KNOWN_TYPES[type] || type);
265 const option = $('<option />', { value: type, text: text });
266 $('#assets_type_select').append(option);
267 }
268
269 if (assetTypes.includes('extension')) {
270 $('#assets_type_select').val('extension');
271 }
272
273 $('#assets_type_select').off('change').on('change', filterAssets);
274 $('#assets_search').off('input').on('input', filterAssets);
275
276 for (const assetType of assetTypes) {
277 await buildAssetTypeSection(assetType);
278 }
279
280 filterAssets();
281 $('#assets_filters').show();
282 $('#assets_menu').show();
283}
284
285/**
286 * Downloads the assets list from the given URL and populates the menu. Shows error message if something goes wrong.
287 * @param {URL} url URL to fetch from
288 */
289async function downloadAssetsList(url) {
290 await updateCurrentAssets();
291 try {
292 const response = await fetch(url, { cache: 'no-cache' });
293 if (!response.ok) {
294 throw new Error('Cannot download the assets list.');
295 }
296 const json = await response.json();
297 if (!Array.isArray(json)) {
298 throw new Error('Assets list is not an array');
299 }
300 await populateAssetsMenu(json);
301 } catch (error) {
302 // Info hint if the user maybe... likely accidentally was trying to install an extension and we wanna help guide them? uwu :3
303 const installButton = $('#third_party_extension_button');
304 flashHighlight(installButton, 10_000);
305 toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
306
307 // Error logged after, to appear on top
308 console.error(error);
309 toastr.error('Problem with assets URL', 'Cannot get assets list');
310 $('#assets-connect-button').addClass('fa-plug-circle-exclamation');
311 $('#assets-connect-button').addClass('redOverlayGlow');
312 }
313}
314
315/**
316 * Previews the asset by opening its URL. If it's an audio asset, it plays a preview sound. Otherwise, it opens the URL in a new tab.
317 * @param {JQuery.Event} e Click event
318 */
319function previewAsset(e) {
320 const href = $(this).attr('href');
321 const audioExtensions = ['.mp3', '.ogg', '.wav'];
322
323 if (audioExtensions.some(ext => href.endsWith(ext))) {
324 e.preventDefault();
325
326 if (previewAudio) {
327 previewAudio.pause();
328
329 if (previewAudio.src === href) {
330 previewAudio = null;
331 return;
332 }
333 }
334
335 previewAudio = new Audio(href);
336 previewAudio.play();
337 return;
338 }
339}
340
341/**
342 * Checks if the asset is already installed.
343 * For extensions, it checks if the extension name is in the list of installed extensions.
344 * For characters, it checks if any character has the same avatar URL.
345 * For other asset types, it checks if any installed asset of the same type has a URL that includes the filename.
346 * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
347 * @param {string} filename Name or ID of the asset
348 * @returns {boolean} True if the asset is installed, false otherwise
349 */
350function isAssetInstalled(assetType, filename) {
351 let assetList = currentAssets[assetType];
352
353 if (assetType == 'extension') {
354 const thirdPartyMarker = 'third-party/';
355 assetList = extensionNames.filter(x => x.startsWith(thirdPartyMarker)).map(x => x.replace(thirdPartyMarker, ''));
356 }
357
358 if (assetType == 'character') {
359 assetList = getContext().characters.map(x => x.avatar);
360 }
361
362 for (const i of assetList) {
363 //console.debug(DEBUG_PREFIX,i,filename)
364 if (i.includes(filename))
365 return true;
366 }
367
368 return false;
369}
370
371/**
372 * Installs the asset by sending a request to the server to download it. If it's an extension, it uses the existing installExtension function.
373 * @param {string} url URL of the asset to download
374 * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
375 * @param {string} filename Name or ID of the asset
376 * @returns {Promise<boolean>} True if the asset was successfully installed, false otherwise
377 */
378async function installAsset(url, assetType, filename) {
379 console.debug(DEBUG_PREFIX, 'Downloading ', url);
380 const category = assetType;
381 try {
382 if (category === 'extension') {
383 console.debug(DEBUG_PREFIX, 'Installing extension ', url);
384 const result = await installExtension(url, false);
385 console.debug(DEBUG_PREFIX, 'Extension installed.');
386 return result;
387 }
388
389 const body = { url, category, filename };
390 const result = await fetch('/api/assets/download', {
391 method: 'POST',
392 headers: getRequestHeaders(),
393 body: JSON.stringify(body),
394 cache: 'no-cache',
395 });
396 if (result.ok) {
397 console.debug(DEBUG_PREFIX, 'Download success.');
398 if (category === 'character') {
399 console.debug(DEBUG_PREFIX, 'Importing character ', filename);
400 const blob = await result.blob();
401 const file = new File([blob], filename, { type: blob.type });
402 const fileNameMap = new Map([[file, filename]]);
403 await processDroppedFiles([file], fileNameMap);
404 console.debug(DEBUG_PREFIX, 'Character downloaded.');
405 }
406 return true;
407 }
408 return false;
409 } catch (err) {
410 console.log(err);
411 return false;
412 }
413}
414
415/**
416 * Deletes the asset by sending a request to the server to delete it. If it's an extension, it uses the existing deleteExtension function.
417 * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
418 * @param {string} filename Name or ID of the asset
419 * @returns {Promise<boolean>} True if the asset was successfully deleted, false otherwise
420 */
421async function deleteAsset(assetType, filename) {
422 console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename);
423 const category = assetType;
424 try {
425 if (category === 'extension') {
426 console.debug(DEBUG_PREFIX, 'Deleting extension ', filename);
427 await deleteExtension(filename);
428 console.debug(DEBUG_PREFIX, 'Extension deleted.');
429 return true;
430 }
431
432 const body = { category, filename };
433 const result = await fetch('/api/assets/delete', {
434 method: 'POST',
435 headers: getRequestHeaders(),
436 body: JSON.stringify(body),
437 cache: 'no-cache',
438 });
439 if (result.ok) {
440 console.debug(DEBUG_PREFIX, 'Deletion success.');
441 return true;
442 }
443 return false;
444 } catch (err) {
445 console.log(err);
446 return false;
447 }
448}
449
450/**
451 * Opens the character browser popup, which shows all available characters and allows downloading them.
452 * @param {boolean} forceDefault If true, it uses the default ASSETS_JSON_URL instead of the one from the input field.
453 * @returns {Promise<void>}
454 */
455async function openCharacterBrowser(forceDefault) {
456 const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val());
457 if (!isValidUrl(url)) {
458 toastr.error('Please enter a valid URL');
459 return;
460 }
461 const fetchResult = await fetch(url, { cache: 'no-cache' });
462 if (!fetchResult.ok) {
463 toastr.error('Cannot download the assets list.');
464 return;
465 }
466 const json = await fetchResult.json();
467 if (!Array.isArray(json)) {
468 toastr.error('Assets list is not an array');
469 return;
470 }
471 const characters = json.filter(x => x && x.type === 'character');
472 if (!characters.length) {
473 toastr.error('No characters found in the assets list', 'Character browser');
474 return;
475 }
476
477 const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'market', {}));
478
479 for (const character of characters.sort((a, b) => a.name.localeCompare(b.name))) {
480 const listElement = template.find(character.highlight ? '.contestWinnersList' : '.featuredCharactersList');
481 const characterElement = $(await renderExtensionTemplateAsync(MODULE_NAME, 'character', character));
482 const downloadButton = characterElement.find('.characterAssetDownloadButton');
483 const checkMark = characterElement.find('.characterAssetCheckMark');
484 const isInstalled = isAssetInstalled('character', character.id);
485
486 downloadButton.toggle(!isInstalled).on('click', async () => {
487 downloadButton.toggleClass('fa-download fa-spinner fa-spin');
488 const result = await installAsset(character.url, 'character', character.id);
489 if (result) {
490 downloadButton.hide();
491 checkMark.show();
492 } else {
493 downloadButton.toggleClass('fa-download fa-spinner fa-spin');
494 }
495 });
496
497 checkMark.toggle(isInstalled).on('click', async () => {
498 toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
499 await SlashCommandParser.commands.go.callback(null, character.id);
500 });
501
502 listElement.append(characterElement);
503 }
504
505 callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false });
506}
507
508//#############################//
509// API Calls //
510//#############################//
511
512async function updateCurrentAssets() {
513 console.debug(DEBUG_PREFIX, 'Checking installed assets...');
514 try {
515 const result = await fetch('/api/assets/get', {
516 method: 'POST',
517 headers: getRequestHeaders({ omitContentType: true }),
518 });
519 currentAssets = result.ok ? (await result.json()) : {};
520 } catch (err) {
521 console.log(err);
522 }
523 console.debug(DEBUG_PREFIX, 'Current assets found:', currentAssets);
524}
525
526
527//#############################//
528// Extension load //
529//#############################//
530
531// This function is called when the extension is loaded
532export async function init() {
533 // This is an example of loading HTML from a file
534 const windowTemplate = await renderExtensionTemplateAsync(MODULE_NAME, 'window', {});
535 const windowHtml = $(windowTemplate);
536
537 const assetsJsonUrl = windowHtml.find('#assets-json-url-field');
538 assetsJsonUrl.val(ASSETS_JSON_URL);
539
540 const charactersButton = windowHtml.find('#assets-characters-button');
541 charactersButton.on('click', async function () {
542 openCharacterBrowser(false);
543 });
544
545 const installHintButton = windowHtml.find('.assets-install-hint-link');
546 installHintButton.on('click', async function () {
547 const installButton = $('#third_party_extension_button');
548 flashHighlight(installButton, 5000);
549 toastr.info(t`Click the flashing button to install extensions.`, t`How to install extensions?`);
550 });
551
552 const connectButton = windowHtml.find('#assets-connect-button');
553 connectButton.on('click', async function () {
554 const urlString = String(assetsJsonUrl.val()).trim();
555 if (!isValidUrl(urlString)) {
556 toastr.error('Please enter a valid URL');
557 return;
558 }
559 const url = new URL(urlString);
560 const rememberKey = `Assets_SkipConfirm_${getStringHash(url.href)}`;
561 const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
562
563 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>${escapeHtml(url.href)}</var>`, {
564 customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
565 onClose: popup => {
566 if (popup.result) {
567 const rememberValue = popup.inputResults.get('assets-remember');
568 accountStorage.setItem(rememberKey, String(rememberValue));
569 }
570 },
571 });
572
573 if (confirmation) {
574 try {
575 console.debug(DEBUG_PREFIX, 'Confimation, loading assets...');
576 downloadAssetsList(url);
577 connectButton.removeClass('fa-plug-circle-exclamation');
578 connectButton.removeClass('redOverlayGlow');
579 connectButton.addClass('fa-plug-circle-check');
580 } catch (error) {
581 console.error('Error:', error);
582 toastr.error(`Cannot get assets list from ${url.href}`);
583 connectButton.removeClass('fa-plug-circle-check');
584 connectButton.addClass('fa-plug-circle-exclamation');
585 connectButton.removeClass('redOverlayGlow');
586 }
587 } else {
588 console.debug(DEBUG_PREFIX, 'Connection refused by user');
589 }
590 });
591
592 windowHtml.find('#assets_filters').hide();
593 $('#assets_container').append(windowHtml);
594
595 eventSource.on(event_types.OPEN_CHARACTER_LIBRARY, async (forceDefault) => {
596 openCharacterBrowser(forceDefault);
597 });
598}