🎨 优化扩展模块,完成ai接入和对话功能
This commit is contained in:
9
data/st-core-scripts/scripts/util/AbortReason.js
Normal file
9
data/st-core-scripts/scripts/util/AbortReason.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export class AbortReason {
|
||||
constructor(reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.reason;
|
||||
}
|
||||
}
|
||||
139
data/st-core-scripts/scripts/util/AccountStorage.js
Normal file
139
data/st-core-scripts/scripts/util/AccountStorage.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { saveSettingsDebounced } from '../../script.js';
|
||||
|
||||
const MIGRATED_MARKER = '__migrated';
|
||||
const MIGRATABLE_KEYS = [
|
||||
/^AlertRegex_/,
|
||||
/^AlertWI_/,
|
||||
/^Assets_SkipConfirm_/,
|
||||
/^Characters_PerPage$/,
|
||||
/^DataBank_sortField$/,
|
||||
/^DataBank_sortOrder$/,
|
||||
/^extension_update_nag$/,
|
||||
/^extensions_sortByName$/,
|
||||
/^FeatherlessModels_PerPage$/,
|
||||
/^GroupMembers_PerPage$/,
|
||||
/^GroupCandidates_PerPage$/,
|
||||
/^LNavLockOn$/,
|
||||
/^LNavOpened$/,
|
||||
/^mediaWarningShown:/,
|
||||
/^NavLockOn$/,
|
||||
/^NavOpened$/,
|
||||
/^Personas_PerPage$/,
|
||||
/^Personas_GridView$/,
|
||||
/^Proxy_SkipConfirm_/,
|
||||
/^qr--executeShortcut$/,
|
||||
/^qr--syntax$/,
|
||||
/^qr--tabSize$/,
|
||||
/^qr--wrap$/,
|
||||
/^RegenerateWithCtrlEnter$/,
|
||||
/^SelectedNavTab$/,
|
||||
/^sendAsNamelessWarningShown$/,
|
||||
/^StoryStringValidationCache$/,
|
||||
/^WINavOpened$/,
|
||||
/^WI_PerPage$/,
|
||||
/^world_info_sort_order$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Provides access to account storage of arbitrary key-value pairs.
|
||||
*/
|
||||
class AccountStorage {
|
||||
/**
|
||||
* @type {Record<string, string>} Storage state
|
||||
*/
|
||||
#state = {};
|
||||
|
||||
/**
|
||||
* @type {boolean} If the storage was initialized
|
||||
*/
|
||||
#ready = false;
|
||||
|
||||
#migrateLocalStorage() {
|
||||
const localStorageKeys = [];
|
||||
for (let i = 0; i < globalThis.localStorage.length; i++) {
|
||||
localStorageKeys.push(globalThis.localStorage.key(i));
|
||||
}
|
||||
for (const key of localStorageKeys) {
|
||||
if (MIGRATABLE_KEYS.some(k => k.test(key))) {
|
||||
const value = globalThis.localStorage.getItem(key);
|
||||
this.#state[key] = value;
|
||||
globalThis.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the account storage.
|
||||
* @param {Object} state Initial state
|
||||
*/
|
||||
init(state) {
|
||||
if (state && typeof state === 'object') {
|
||||
this.#state = Object.assign(this.#state, state);
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(this.#state, MIGRATED_MARKER)) {
|
||||
this.#migrateLocalStorage();
|
||||
this.#state[MIGRATED_MARKER] = '1';
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
this.#ready = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a key in account storage.
|
||||
* @param {string} key Key to get
|
||||
* @returns {string|null} Value of the key
|
||||
*/
|
||||
getItem(key) {
|
||||
if (!this.#ready) {
|
||||
console.warn(`AccountStorage not ready (trying to read from ${key})`);
|
||||
}
|
||||
|
||||
return Object.hasOwn(this.#state, key) ? String(this.#state[key]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a key in account storage.
|
||||
* @param {string} key Key to set
|
||||
* @param {string} value Value to set
|
||||
*/
|
||||
setItem(key, value) {
|
||||
if (!this.#ready) {
|
||||
console.warn(`AccountStorage not ready (trying to write to ${key})`);
|
||||
}
|
||||
|
||||
this.#state[key] = String(value);
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a key from account storage.
|
||||
* @param {string} key Key to remove
|
||||
*/
|
||||
removeItem(key) {
|
||||
if (!this.#ready) {
|
||||
console.warn(`AccountStorage not ready (trying to remove ${key})`);
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(this.#state, key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete this.#state[key];
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a snapshot of the storage state.
|
||||
* @returns {Record<string, string>} A deep clone of the storage state
|
||||
*/
|
||||
getState() {
|
||||
return structuredClone(this.#state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Account storage instance.
|
||||
*/
|
||||
export const accountStorage = new AccountStorage();
|
||||
44
data/st-core-scripts/scripts/util/SimpleMutex.js
Normal file
44
data/st-core-scripts/scripts/util/SimpleMutex.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* A simple mutex class to prevent concurrent updates.
|
||||
*/
|
||||
export class SimpleMutex {
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
isBusy = false;
|
||||
|
||||
/**
|
||||
* @type {Function}
|
||||
*/
|
||||
callback = () => {};
|
||||
|
||||
/**
|
||||
* Constructs a SimpleMutex.
|
||||
* @param {Function} callback Callback function.
|
||||
*/
|
||||
constructor(callback) {
|
||||
this.isBusy = false;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the mutex by calling the callback if not busy.
|
||||
* @param {...any} args Callback args
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async update(...args) {
|
||||
// Don't touch me I'm busy...
|
||||
if (this.isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
// I'm free. Let's update!
|
||||
try {
|
||||
this.isBusy = true;
|
||||
await this.callback(...args);
|
||||
}
|
||||
finally {
|
||||
this.isBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
data/st-core-scripts/scripts/util/StructuredCloneMap.js
Normal file
56
data/st-core-scripts/scripts/util/StructuredCloneMap.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* A specialized Map class that provides consistent data storage by performing deep cloning of values.
|
||||
*
|
||||
* @template K, V
|
||||
* @extends Map<K, V>
|
||||
*/
|
||||
export class StructuredCloneMap extends Map {
|
||||
/**
|
||||
* Constructs a new StructuredCloneMap.
|
||||
* @param {object} options - Options for the map
|
||||
* @param {boolean} options.cloneOnGet - Whether to clone the value when getting it from the map
|
||||
* @param {boolean} options.cloneOnSet - Whether to clone the value when setting it in the map
|
||||
*/
|
||||
constructor({ cloneOnGet, cloneOnSet } = { cloneOnGet: true, cloneOnSet: true }) {
|
||||
super();
|
||||
this.cloneOnGet = cloneOnGet;
|
||||
this.cloneOnSet = cloneOnSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.
|
||||
*
|
||||
* The set value will always be a deep clone of the provided value to provide consistent data storage.
|
||||
*
|
||||
* @param {K} key - The key to set
|
||||
* @param {V} value - The value to set
|
||||
* @returns {this} The updated map
|
||||
*/
|
||||
set(key, value) {
|
||||
if (!this.cloneOnSet) {
|
||||
return super.set(key, value);
|
||||
}
|
||||
|
||||
const clonedValue = structuredClone(value);
|
||||
super.set(key, clonedValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specified element from the Map object.
|
||||
* If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
|
||||
*
|
||||
* The returned value will always be a deep clone of the cached value.
|
||||
*
|
||||
* @param {K} key - The key to get the value for
|
||||
* @returns {V | undefined} Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
|
||||
*/
|
||||
get(key) {
|
||||
if (!this.cloneOnGet) {
|
||||
return super.get(key);
|
||||
}
|
||||
|
||||
const value = super.get(key);
|
||||
return structuredClone(value);
|
||||
}
|
||||
}
|
||||
30
data/st-core-scripts/scripts/util/showdown-patch.js
Normal file
30
data/st-core-scripts/scripts/util/showdown-patch.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Patches showdown to unrestrictedly unhash HTML spans.
|
||||
* @param {import('showdown')} showdown The showdown object to patch
|
||||
*/
|
||||
export function addShowdownPatch(showdown) {
|
||||
showdown.subParser('unhashHTMLSpans', function (text, options, globals) {
|
||||
'use strict';
|
||||
text = globals.converter._dispatch('unhashHTMLSpans.before', text, options, globals);
|
||||
|
||||
for (var i = 0; i < globals.gHtmlSpans.length; ++i) {
|
||||
var repText = globals.gHtmlSpans[i],
|
||||
// limiter to prevent infinite loop (assume 10 as limit for recurse)
|
||||
limit = 0;
|
||||
|
||||
while (/¨C(\d+)C/.test(repText)) {
|
||||
var num = RegExp.$1;
|
||||
repText = repText.replace('¨C' + num + 'C', globals.gHtmlSpans[num]);
|
||||
if (limit === 10000) {
|
||||
console.error('maximum nesting of 10000 spans reached!!!');
|
||||
break;
|
||||
}
|
||||
++limit;
|
||||
}
|
||||
text = text.replace('¨C' + i + 'C', repText);
|
||||
}
|
||||
|
||||
text = globals.converter._dispatch('unhashHTMLSpans.after', text, options, globals);
|
||||
return text;
|
||||
});
|
||||
}
|
||||
69
data/st-core-scripts/scripts/util/stream-fadein.js
Normal file
69
data/st-core-scripts/scripts/util/stream-fadein.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { morphdom } from '../../lib.js';
|
||||
|
||||
/**
|
||||
* Check if the current browser supports native segmentation function.
|
||||
* @returns {boolean} True if the Segmenter is supported by the current browser.
|
||||
*/
|
||||
export function isSegmenterSupported() {
|
||||
return typeof Intl.Segmenter === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment text in the given HTML content using Intl.Segmenter.
|
||||
* @param {HTMLElement} htmlElement Target HTML element
|
||||
* @param {string} htmlContent HTML content to segment
|
||||
* @param {'word'|'grapheme'|'sentence'} [granularity='word'] Text split granularity
|
||||
*/
|
||||
export function segmentTextInElement(htmlElement, htmlContent, granularity = 'word') {
|
||||
htmlElement.innerHTML = htmlContent;
|
||||
|
||||
if (!isSegmenterSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Support more locales, make granularity configurable.
|
||||
const segmenter = new Intl.Segmenter('en-US', { granularity });
|
||||
const textNodes = [];
|
||||
const walker = document.createTreeWalker(htmlElement, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const textNode = /** @type {Text} */ (walker.currentNode);
|
||||
|
||||
// Skip ancestors of code/pre
|
||||
if (textNode.parentElement && textNode.parentElement.closest('pre, code')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip text nodes that are empty or only whitespace
|
||||
if (/^\s*$/.test(textNode.data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
textNodes.push(textNode);
|
||||
}
|
||||
|
||||
// Split every text node into segments using spans
|
||||
for (const textNode of textNodes) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const segments = segmenter.segment(textNode.data);
|
||||
for (const segment of segments) {
|
||||
// TODO: Apply a different class for different segment length/content?
|
||||
// For now, just use a single class for all segments.
|
||||
const span = document.createElement('span');
|
||||
span.innerText = segment.segment;
|
||||
span.className = 'text_segment';
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
textNode.replaceWith(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stream fade-in effect to the given message text element by morphing its content.
|
||||
* @param {HTMLElement} messageTextElement Message text element
|
||||
* @param {string} htmlContent New HTML content to apply
|
||||
*/
|
||||
export function applyStreamFadeIn(messageTextElement, htmlContent) {
|
||||
const targetElement = /** @type {HTMLElement} */ (messageTextElement.cloneNode());
|
||||
segmentTextInElement(targetElement, htmlContent);
|
||||
morphdom(messageTextElement, targetElement);
|
||||
}
|
||||
Reference in New Issue
Block a user