mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
perf (frontend panel): added a frontend panel runtime, significantly reducing the number of DOM nodes.
This commit is contained in:
parent
ef5e596d1b
commit
01ec179cd6
18 changed files with 1382 additions and 5 deletions
1
new-api
1
new-api
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 4e1b05e987077ae763c9d8c8407f002a2e43f992
|
||||
89
scripts/audit-panel-runtime-deps.mjs
Normal file
89
scripts/audit-panel-runtime-deps.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const SEARCH_ROOTS = [
|
||||
'src',
|
||||
'scripts',
|
||||
'src-tauri',
|
||||
'.cache',
|
||||
];
|
||||
|
||||
const RG_EXCLUDES = [
|
||||
'!node_modules/**',
|
||||
'!src-tauri/target/**',
|
||||
'!release/**',
|
||||
'!dist/**',
|
||||
'!.git/**',
|
||||
];
|
||||
|
||||
const QUERIES = [
|
||||
{
|
||||
title: 'Phase 1: Anchor Zone (SPresets_script)',
|
||||
patterns: [
|
||||
'#openai_preset_import_file',
|
||||
'#completion_prompt_manager',
|
||||
'#extensions-settings-button .drawer-toggle',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Phase 1: API Connections Gates',
|
||||
patterns: [
|
||||
"#main_api').on('change",
|
||||
"trigger('change')",
|
||||
'toggleChatCompletionForms',
|
||||
'showTypeSpecificControls',
|
||||
'[data-source]',
|
||||
'[data-tg-type]',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Phase 1: Extensions Gates',
|
||||
patterns: [
|
||||
'regex_container',
|
||||
'extension_container',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function runRg(pattern) {
|
||||
const args = [
|
||||
'--no-ignore',
|
||||
'--hidden',
|
||||
'-n',
|
||||
...RG_EXCLUDES.flatMap((g) => ['-g', g]),
|
||||
pattern,
|
||||
...SEARCH_ROOTS,
|
||||
];
|
||||
|
||||
try {
|
||||
return execFileSync('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }).trim();
|
||||
} catch (error) {
|
||||
const stderr = String(error?.stderr || '').trim();
|
||||
// rg exits with 1 on no matches; treat as empty.
|
||||
if (String(error?.status) === '1') {
|
||||
return '';
|
||||
}
|
||||
throw new Error(`rg failed for pattern '${pattern}': ${stderr || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateLines(text, maxLines = 40) {
|
||||
const lines = String(text || '').split(/\r?\n/).filter(Boolean);
|
||||
if (lines.length <= maxLines) {
|
||||
return { total: lines.length, text: lines.join('\n') };
|
||||
}
|
||||
const shown = lines.slice(0, maxLines).join('\n');
|
||||
return { total: lines.length, text: `${shown}\n... (${lines.length - maxLines} more lines)` };
|
||||
}
|
||||
|
||||
for (const query of QUERIES) {
|
||||
console.log(`\n## ${query.title}`);
|
||||
for (const pattern of query.patterns) {
|
||||
const output = runRg(pattern);
|
||||
if (!output) {
|
||||
console.log(`\n- ${pattern}: (no matches)`);
|
||||
continue;
|
||||
}
|
||||
const truncated = truncateLines(output);
|
||||
console.log(`\n- ${pattern} (${truncated.total} lines):\n${truncated.text}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use serde_json::Value;
|
|||
pub struct TauriTavernSettingsDto {
|
||||
pub updates: TauriTavernUpdateSettingsDto,
|
||||
pub perf_profile: String,
|
||||
pub panel_runtime_profile: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -25,6 +26,7 @@ pub struct StartupUpdatePopupSettingsDto {
|
|||
pub struct UpdateTauriTavernSettingsDto {
|
||||
pub updates: Option<TauriTavernUpdateSettingsDto>,
|
||||
pub perf_profile: Option<String>,
|
||||
pub panel_runtime_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
|
|
@ -95,6 +97,7 @@ impl From<TauriTavernSettings> for TauriTavernSettingsDto {
|
|||
Self {
|
||||
updates: TauriTavernUpdateSettingsDto::from(settings.updates),
|
||||
perf_profile: settings.perf_profile,
|
||||
panel_runtime_profile: settings.panel_runtime_profile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ impl SettingsService {
|
|||
settings.perf_profile = perf_profile;
|
||||
}
|
||||
|
||||
if let Some(panel_runtime_profile) = dto.panel_runtime_profile {
|
||||
settings.panel_runtime_profile = panel_runtime_profile;
|
||||
}
|
||||
|
||||
self.settings_repository
|
||||
.save_tauritavern_settings(&settings)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -5,11 +5,17 @@ fn default_perf_profile() -> String {
|
|||
"auto".to_string()
|
||||
}
|
||||
|
||||
fn default_panel_runtime_profile() -> String {
|
||||
"compat".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TauriTavernSettings {
|
||||
pub updates: TauriTavernUpdateSettings,
|
||||
#[serde(default = "default_perf_profile")]
|
||||
pub perf_profile: String,
|
||||
#[serde(default = "default_panel_runtime_profile")]
|
||||
pub panel_runtime_profile: String,
|
||||
}
|
||||
|
||||
impl Default for TauriTavernSettings {
|
||||
|
|
@ -17,6 +23,7 @@ impl Default for TauriTavernSettings {
|
|||
Self {
|
||||
updates: TauriTavernUpdateSettings::default(),
|
||||
perf_profile: default_perf_profile(),
|
||||
panel_runtime_profile: default_panel_runtime_profile(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { callGenericPopup, POPUP_RESULT, POPUP_TYPE, Popup } from '../../popup.js';
|
||||
import { isMobile } from '../../RossAscends-mods.js';
|
||||
import { t, translate } from '../../i18n.js';
|
||||
import { getTauriTavernSettings, updateTauriTavernSettings } from '../../../tauri-bridge.js';
|
||||
|
||||
const TAURITAVERN_SETTINGS_BUTTON_ID = 'tauritavern_settings_button';
|
||||
const LAN_SYNC_DEVICES_CHANGED_EVENT = 'tauritavern:lan_sync_devices_changed';
|
||||
|
|
@ -50,7 +51,7 @@ function bindLanSyncButton() {
|
|||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
void openLanSyncPopup();
|
||||
runOrPopup(openTauriTavernSettingsPopup);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +291,84 @@ async function scanPairUriFromCamera() {
|
|||
return content;
|
||||
}
|
||||
|
||||
async function openTauriTavernSettingsPopup() {
|
||||
const settings = await getTauriTavernSettings();
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'flex-container flexFlowColumn';
|
||||
root.style.gap = '12px';
|
||||
root.innerHTML = `
|
||||
<div class="flex-container flexFlowColumn" style="gap: 10px;">
|
||||
<b data-i18n="TauriTavern Settings">TauriTavern Settings</b>
|
||||
|
||||
<div class="flex-container flexFlowColumn" style="gap: 8px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 10px;">
|
||||
<b data-i18n="Panel Runtime">Panel Runtime</b>
|
||||
<div class="flex-container alignItemsBaseline" style="gap: 10px; flex-wrap: wrap;">
|
||||
<span data-i18n="Mode">Mode</span>:
|
||||
<select id="tt-panel-runtime-profile" class="text_pole" style="margin: 0; width: auto; min-width: 260px; max-width: 100%; flex: 1;">
|
||||
<option value="compat" data-i18n="Compatibility (Recommended)">Compatibility (Recommended)</option>
|
||||
<option value="aggressive" data-i18n="Aggressive (More DOM Parking)">Aggressive (More DOM Parking)</option>
|
||||
<option value="off" data-i18n="Off (Legacy)">Off (Legacy)</option>
|
||||
</select>
|
||||
</div>
|
||||
<small style="opacity: 0.85;" data-i18n="Requires reload to apply.">Requires reload to apply.</small>
|
||||
</div>
|
||||
|
||||
<div class="flex-container flexFlowColumn" style="gap: 8px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 10px;">
|
||||
<b data-i18n="LAN Sync">LAN Sync</b>
|
||||
<div class="flex-container flexFlowRow" style="gap: 10px;">
|
||||
<div id="tt-open-lan-sync" class="menu_button" data-i18n="Open">Open</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const profileSelect = root.querySelector('#tt-panel-runtime-profile');
|
||||
if (!(profileSelect instanceof HTMLSelectElement)) {
|
||||
throw new Error('TauriTavern settings: panel runtime selector not found');
|
||||
}
|
||||
|
||||
const currentProfile = settings.panel_runtime_profile;
|
||||
profileSelect.value = typeof currentProfile === 'string' && currentProfile ? currentProfile : 'compat';
|
||||
|
||||
const openLanSyncButton = root.querySelector('#tt-open-lan-sync');
|
||||
if (!(openLanSyncButton instanceof HTMLElement)) {
|
||||
throw new Error('TauriTavern settings: LAN sync button not found');
|
||||
}
|
||||
openLanSyncButton.addEventListener('click', () => runOrPopup(openLanSyncPopup));
|
||||
|
||||
const result = await callGenericPopup(root, POPUP_TYPE.CONFIRM, '', {
|
||||
okButton: translate('Save'),
|
||||
cancelButton: translate('Close'),
|
||||
allowVerticalScrolling: true,
|
||||
wide: false,
|
||||
large: false,
|
||||
});
|
||||
|
||||
if (result !== POPUP_RESULT.AFFIRMATIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProfile = String(profileSelect.value || '').trim();
|
||||
if (!nextProfile || nextProfile === currentProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateTauriTavernSettings({
|
||||
panel_runtime_profile: nextProfile,
|
||||
});
|
||||
|
||||
// Keep in sync with:
|
||||
// - src/tauri/main/services/panel-runtime/preinstall.js
|
||||
// - src/tauri/main/services/panel-runtime/install.js
|
||||
//
|
||||
// Mirror the chosen profile so bootstrap can synchronously honor `off`
|
||||
// before Tauri settings are loaded.
|
||||
localStorage.setItem('tt:panelRuntimeProfile', nextProfile);
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function openLanSyncPopup() {
|
||||
const panel = buildLanSyncPopup();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
// @ts-check
|
||||
|
||||
import { PanelRuntimeKind } from '../../services/panel-runtime/panel-runtime-kinds.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../../services/embedded-runtime/embedded-runtime-manager.js').createEmbeddedRuntimeManager} createEmbeddedRuntimeManager
|
||||
* @typedef {ReturnType<createEmbeddedRuntimeManager>} EmbeddedRuntimeManager
|
||||
*/
|
||||
|
||||
const MAIN_API_TO_CONNECTOR_ID = Object.freeze({
|
||||
koboldhorde: 'kobold_horde',
|
||||
kobold: 'kobold_api',
|
||||
novel: 'novel_api',
|
||||
textgenerationwebui: 'textgenerationwebui_api',
|
||||
openai: 'openai_api',
|
||||
});
|
||||
|
||||
/** @type {boolean} */
|
||||
let preinstalled = false;
|
||||
|
||||
/** @type {EmbeddedRuntimeManager | null} */
|
||||
let manager = null;
|
||||
|
||||
/** @type {Map<string, string> | null} */
|
||||
let providerSlotIdByMainApi = null;
|
||||
|
||||
/** @type {ReturnType<typeof createAttributeChildGate> | null} */
|
||||
let openaiSourceGate = null;
|
||||
|
||||
/** @type {ReturnType<typeof createAttributeChildGate> | null} */
|
||||
let textgenTypeGate = null;
|
||||
|
||||
function requireJQuery() {
|
||||
const jq = /** @type {any} */ (globalThis).jQuery || /** @type {any} */ (globalThis).$;
|
||||
if (typeof jq !== 'function') {
|
||||
throw new Error('PanelRuntime: jQuery not found');
|
||||
}
|
||||
return jq;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function mustGetElementById(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
throw new Error(`PanelRuntime: #${id} not found`);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} host
|
||||
* @param {DocumentFragment} frag
|
||||
*/
|
||||
function parkChildrenInto(host, frag) {
|
||||
while (host.firstChild) {
|
||||
frag.appendChild(host.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} host
|
||||
* @param {DocumentFragment} frag
|
||||
*/
|
||||
function restoreChildren(host, frag) {
|
||||
if (!frag.firstChild) {
|
||||
return;
|
||||
}
|
||||
host.appendChild(frag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a slot that parks/restores an element's children while keeping the
|
||||
* element itself connected for selector compatibility.
|
||||
*
|
||||
* @param {EmbeddedRuntimeManager} manager
|
||||
* @param {{ slotId: string; element: HTMLElement; initialVisible: boolean }} config
|
||||
*/
|
||||
function registerChildParkingSlot(manager, { slotId, element, initialVisible }) {
|
||||
/** @type {DocumentFragment} */
|
||||
let parked = document.createDocumentFragment();
|
||||
|
||||
manager.register({
|
||||
id: slotId,
|
||||
kind: PanelRuntimeKind.SubtreeGate,
|
||||
element,
|
||||
visibilityMode: 'manual',
|
||||
initialVisible,
|
||||
hydrate: () => {
|
||||
restoreChildren(element, parked);
|
||||
},
|
||||
dehydrate: () => {
|
||||
parkChildrenInto(element, parked);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @param {string} attr
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
function collectAttributeHosts(root, attr) {
|
||||
return Array.from(root.querySelectorAll(`[${attr}]`)).filter((el) => el instanceof HTMLElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @param {{
|
||||
* attr: string;
|
||||
* modeAttr: string;
|
||||
* includeAll?: boolean;
|
||||
* }} config
|
||||
*/
|
||||
function createAttributeChildGate(root, { attr, modeAttr, includeAll = false }) {
|
||||
/** @type {Array<{ host: HTMLElement; parked: DocumentFragment; values: string[]; mode: 'except' | 'include' }>} */
|
||||
const gates = [];
|
||||
for (const host of collectAttributeHosts(root, attr)) {
|
||||
const rawList = String(host.getAttribute(attr) || '').trim();
|
||||
if (!rawList) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const values = rawList.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const rawMode = String(host.getAttribute(modeAttr) || '').trim().toLowerCase();
|
||||
const mode = rawMode === 'except' ? 'except' : 'include';
|
||||
|
||||
gates.push({
|
||||
host,
|
||||
parked: document.createDocumentFragment(),
|
||||
values,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
apply: (value) => {
|
||||
const v = String(value || '').trim();
|
||||
for (const g of gates) {
|
||||
const matchesValue = g.values.includes(v);
|
||||
const shouldBeVisible = g.mode === 'except'
|
||||
? !matchesValue
|
||||
: matchesValue || (includeAll && g.values.includes('all'));
|
||||
|
||||
if (shouldBeVisible) {
|
||||
restoreChildren(g.host, g.parked);
|
||||
} else {
|
||||
parkChildrenInto(g.host, g.parked);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
function currentSelectValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!(el instanceof HTMLSelectElement)) {
|
||||
return '';
|
||||
}
|
||||
return String(el.value || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates the selected provider subtree and parks all others, then applies any
|
||||
* nested gates (OpenAI source / Textgen type) for the active provider.
|
||||
*
|
||||
* Must run synchronously inside the `change` event so downstream handlers can
|
||||
* safely query their expected DOM.
|
||||
*
|
||||
* @param {string} mainApi
|
||||
*/
|
||||
function handleMainApiChange(mainApi) {
|
||||
if (!manager || !providerSlotIdByMainApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = String(mainApi || '').trim();
|
||||
for (const [api, slotId] of providerSlotIdByMainApi.entries()) {
|
||||
manager.setVisible(slotId, api === next);
|
||||
}
|
||||
manager.reconcile();
|
||||
|
||||
if (next === 'openai' && openaiSourceGate) {
|
||||
openaiSourceGate.apply(currentSelectValue('chat_completion_source') || 'openai');
|
||||
}
|
||||
if (next === 'textgenerationwebui' && textgenTypeGate) {
|
||||
textgenTypeGate.apply(currentSelectValue('textgen_type') || '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} source
|
||||
*/
|
||||
function handleChatCompletionSourceChange(source) {
|
||||
if (!openaiSourceGate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainApi = currentSelectValue('main_api');
|
||||
if (mainApi !== 'openai') {
|
||||
return;
|
||||
}
|
||||
|
||||
openaiSourceGate.apply(source || 'openai');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
function handleTextgenTypeChange(type) {
|
||||
if (!textgenTypeGate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainApi = currentSelectValue('main_api');
|
||||
if (mainApi !== 'textgenerationwebui') {
|
||||
return;
|
||||
}
|
||||
|
||||
textgenTypeGate.apply(type || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 (preinstall): register early `change` listeners so our gate handlers
|
||||
* run before SillyTavern's own handlers (including jQuery `.trigger('change')`).
|
||||
*/
|
||||
export function preinstallApiConnectionsSubtreeGates() {
|
||||
if (preinstalled) {
|
||||
return;
|
||||
}
|
||||
preinstalled = true;
|
||||
|
||||
const $ = requireJQuery();
|
||||
|
||||
const mainApi = mustGetElementById('main_api');
|
||||
if (!(mainApi instanceof HTMLSelectElement)) {
|
||||
throw new Error('PanelRuntime: #main_api is not a <select>');
|
||||
}
|
||||
$(mainApi).on('change.ttPanelRuntimeGate', () => {
|
||||
handleMainApiChange(String(mainApi.value || ''));
|
||||
});
|
||||
|
||||
const chatCompletionSource = mustGetElementById('chat_completion_source');
|
||||
if (!(chatCompletionSource instanceof HTMLSelectElement)) {
|
||||
throw new Error('PanelRuntime: #chat_completion_source is not a <select>');
|
||||
}
|
||||
$(chatCompletionSource).on('change.ttPanelRuntimeGate', () => {
|
||||
handleChatCompletionSourceChange(String(chatCompletionSource.value || ''));
|
||||
});
|
||||
|
||||
const textgenType = mustGetElementById('textgen_type');
|
||||
if (!(textgenType instanceof HTMLSelectElement)) {
|
||||
throw new Error('PanelRuntime: #textgen_type is not a <select>');
|
||||
}
|
||||
$(textgenType).on('change.ttPanelRuntimeGate', () => {
|
||||
handleTextgenTypeChange(String(textgenType.value || ''));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 (activate): build gates and immediately park inactive subtrees.
|
||||
*
|
||||
* @param {{ manager: EmbeddedRuntimeManager }} options
|
||||
*/
|
||||
export function activateApiConnectionsSubtreeGates({ manager: nextManager }) {
|
||||
if (!nextManager) {
|
||||
throw new Error('activateApiConnectionsSubtreeGates requires manager');
|
||||
}
|
||||
if (manager) {
|
||||
throw new Error('activateApiConnectionsSubtreeGates called twice');
|
||||
}
|
||||
|
||||
manager = nextManager;
|
||||
|
||||
const selectedMainApi = currentSelectValue('main_api');
|
||||
providerSlotIdByMainApi = new Map();
|
||||
for (const [mainApi, connectorId] of Object.entries(MAIN_API_TO_CONNECTOR_ID)) {
|
||||
const element = mustGetElementById(connectorId);
|
||||
const slotId = `tt:gate:rm_api_block:main_api:${mainApi}`;
|
||||
providerSlotIdByMainApi.set(mainApi, slotId);
|
||||
registerChildParkingSlot(manager, { slotId, element, initialVisible: mainApi === selectedMainApi });
|
||||
}
|
||||
|
||||
const openaiApi = mustGetElementById('openai_api');
|
||||
openaiSourceGate = createAttributeChildGate(openaiApi, {
|
||||
attr: 'data-source',
|
||||
modeAttr: 'data-source-mode',
|
||||
});
|
||||
|
||||
const textgenApi = mustGetElementById('textgenerationwebui_api');
|
||||
textgenTypeGate = createAttributeChildGate(textgenApi, {
|
||||
attr: 'data-tg-type',
|
||||
modeAttr: 'data-tg-type-mode',
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
manager.reconcile();
|
||||
|
||||
if (selectedMainApi === 'openai') {
|
||||
openaiSourceGate.apply(currentSelectValue('chat_completion_source') || 'openai');
|
||||
}
|
||||
if (selectedMainApi === 'textgenerationwebui') {
|
||||
textgenTypeGate.apply(currentSelectValue('textgen_type') || '');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// @ts-check
|
||||
|
||||
import { PanelRuntimeKind } from '../../services/panel-runtime/panel-runtime-kinds.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../../services/embedded-runtime/embedded-runtime-manager.js').createEmbeddedRuntimeManager} createEmbeddedRuntimeManager
|
||||
* @typedef {ReturnType<createEmbeddedRuntimeManager>} EmbeddedRuntimeManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} drawerContent
|
||||
*/
|
||||
function isDrawerOpen(drawerContent) {
|
||||
return drawerContent.classList.contains('openDrawer') && !drawerContent.classList.contains('closedDrawer');
|
||||
}
|
||||
|
||||
const COMPAT_ALWAYS_CONNECTED_EXTENSION_CONTAINER_IDS = new Set([
|
||||
'regex_container',
|
||||
'qr_container',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function mustGetElementById(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
throw new Error(`PanelRuntime: #${id} not found`);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} host
|
||||
* @param {DocumentFragment} frag
|
||||
*/
|
||||
function parkChildrenInto(host, frag) {
|
||||
while (host.firstChild) {
|
||||
frag.appendChild(host.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} host
|
||||
* @param {DocumentFragment} frag
|
||||
*/
|
||||
function restoreChildren(host, frag) {
|
||||
if (!frag.firstChild) {
|
||||
return;
|
||||
}
|
||||
host.appendChild(frag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Park extension settings DOM under `#rm_extensions_block` while the
|
||||
* drawer is closed, keeping the mount containers connected for compatibility.
|
||||
*
|
||||
* @param {{ manager: EmbeddedRuntimeManager }} options
|
||||
*/
|
||||
export function installExtensionsSubtreeGates({ manager }) {
|
||||
if (!manager) {
|
||||
throw new Error('installExtensionsSubtreeGates requires manager');
|
||||
}
|
||||
|
||||
const host = mustGetElementById('rm_extensions_block');
|
||||
const alwaysConnectedContainerIds = manager.profile === 'compat'
|
||||
? COMPAT_ALWAYS_CONNECTED_EXTENSION_CONTAINER_IDS
|
||||
: null;
|
||||
|
||||
const drawer = host.closest('.drawer');
|
||||
if (!(drawer instanceof HTMLElement)) {
|
||||
throw new Error('PanelRuntime(extensions): .drawer ancestor not found');
|
||||
}
|
||||
|
||||
const toggle = drawer.querySelector('.drawer-toggle');
|
||||
if (!(toggle instanceof HTMLElement)) {
|
||||
throw new Error('PanelRuntime(extensions): .drawer-toggle not found');
|
||||
}
|
||||
|
||||
const containers = Array.from(host.querySelectorAll('.extension_container')).filter((el) => el instanceof HTMLElement);
|
||||
/** @type {Map<HTMLElement, DocumentFragment>} */
|
||||
const parkedByContainer = new Map();
|
||||
for (const container of containers) {
|
||||
if (alwaysConnectedContainerIds && alwaysConnectedContainerIds.has(container.id)) {
|
||||
continue;
|
||||
}
|
||||
parkedByContainer.set(container, document.createDocumentFragment());
|
||||
}
|
||||
|
||||
const slotId = 'tt:gate:rm_extensions_block:extension_containers';
|
||||
|
||||
manager.register({
|
||||
id: slotId,
|
||||
kind: PanelRuntimeKind.SubtreeGate,
|
||||
element: host,
|
||||
visibilityMode: 'manual',
|
||||
initialVisible: isDrawerOpen(host),
|
||||
hydrate: () => {
|
||||
for (const [container, frag] of parkedByContainer.entries()) {
|
||||
restoreChildren(container, frag);
|
||||
}
|
||||
},
|
||||
dehydrate: () => {
|
||||
for (const [container, frag] of parkedByContainer.entries()) {
|
||||
parkChildrenInto(container, frag);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onToggleClickCapture = () => {
|
||||
manager.setVisible(slotId, true);
|
||||
manager.reconcile();
|
||||
};
|
||||
toggle.addEventListener('click', onToggleClickCapture, true);
|
||||
|
||||
const classObserver = new MutationObserver(() => {
|
||||
const open = isDrawerOpen(host);
|
||||
manager.setVisible(slotId, open);
|
||||
if (open) {
|
||||
manager.reconcile();
|
||||
}
|
||||
});
|
||||
|
||||
classObserver.observe(host, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
manager.reconcile();
|
||||
|
||||
return {
|
||||
slotId,
|
||||
dispose: () => {
|
||||
classObserver.disconnect();
|
||||
toggle.removeEventListener('click', onToggleClickCapture, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
// @ts-check
|
||||
|
||||
import { PanelRuntimeKind } from '../../services/panel-runtime/panel-runtime-kinds.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../../services/embedded-runtime/embedded-runtime-manager.js').createEmbeddedRuntimeManager} createEmbeddedRuntimeManager
|
||||
* @typedef {ReturnType<createEmbeddedRuntimeManager>} EmbeddedRuntimeManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} drawerContent
|
||||
*/
|
||||
function isDrawerOpen(drawerContent) {
|
||||
return drawerContent.classList.contains('openDrawer') && !drawerContent.classList.contains('closedDrawer');
|
||||
}
|
||||
|
||||
const LEFT_NAV_MAIN_API_BLOCKS = Object.freeze({
|
||||
koboldhorde: {
|
||||
settingsId: 'kobold_api-settings',
|
||||
presetsId: 'kobold_api-presets',
|
||||
rangesId: null,
|
||||
streamingId: null,
|
||||
},
|
||||
kobold: {
|
||||
settingsId: 'kobold_api-settings',
|
||||
presetsId: 'kobold_api-presets',
|
||||
rangesId: null,
|
||||
streamingId: 'streaming_kobold_block',
|
||||
},
|
||||
textgenerationwebui: {
|
||||
settingsId: 'textgenerationwebui_api-settings',
|
||||
presetsId: 'textgenerationwebui_api-presets',
|
||||
rangesId: null,
|
||||
streamingId: 'streaming_textgenerationwebui_block',
|
||||
},
|
||||
novel: {
|
||||
settingsId: 'novel_api-settings',
|
||||
presetsId: 'novel_api-presets',
|
||||
rangesId: 'range_block_novel',
|
||||
streamingId: 'streaming_novel_block',
|
||||
},
|
||||
openai: {
|
||||
settingsId: 'openai_settings',
|
||||
presetsId: 'openai_api-presets',
|
||||
rangesId: 'range_block_openai',
|
||||
streamingId: null,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} display
|
||||
*/
|
||||
function setDisplay(id, display) {
|
||||
const el = document.getElementById(id);
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
throw new Error(`PanelParking(left-nav): #${id} not found`);
|
||||
}
|
||||
el.style.display = display;
|
||||
}
|
||||
|
||||
function syncLeftNavMainApiUi() {
|
||||
const mainApi = document.getElementById('main_api');
|
||||
if (!(mainApi instanceof HTMLSelectElement)) {
|
||||
throw new Error('PanelParking(left-nav): #main_api <select> not found');
|
||||
}
|
||||
|
||||
const selected = String(mainApi.value || '').trim();
|
||||
const active = /** @type {keyof typeof LEFT_NAV_MAIN_API_BLOCKS} */ (selected);
|
||||
if (!Object.prototype.hasOwnProperty.call(LEFT_NAV_MAIN_API_BLOCKS, active)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [apiName, blocks] of Object.entries(LEFT_NAV_MAIN_API_BLOCKS)) {
|
||||
if (apiName === active) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setDisplay(blocks.settingsId, 'none');
|
||||
setDisplay(blocks.presetsId, 'none');
|
||||
if (blocks.rangesId) {
|
||||
setDisplay(blocks.rangesId, 'none');
|
||||
}
|
||||
if (blocks.streamingId) {
|
||||
setDisplay(blocks.streamingId, 'none');
|
||||
}
|
||||
}
|
||||
|
||||
const activeBlocks = LEFT_NAV_MAIN_API_BLOCKS[active];
|
||||
if (activeBlocks.streamingId) {
|
||||
setDisplay(activeBlocks.streamingId, 'block');
|
||||
}
|
||||
setDisplay(activeBlocks.settingsId, 'block');
|
||||
if (activeBlocks.rangesId) {
|
||||
setDisplay(activeBlocks.rangesId, 'block');
|
||||
}
|
||||
setDisplay(activeBlocks.presetsId, active === 'openai' ? 'flex' : 'block');
|
||||
|
||||
setDisplay('ai_module_block_novel', active === 'novel' ? 'block' : 'none');
|
||||
|
||||
const textgenType = document.getElementById('textgen_type');
|
||||
const isOpenRouter = textgenType instanceof HTMLSelectElement && String(textgenType.value || '').trim() === 'openrouter';
|
||||
const showPromptCost = active === 'textgenerationwebui' && isOpenRouter;
|
||||
setDisplay('prompt_cost_block', showPromptCost ? 'block' : 'none');
|
||||
|
||||
setDisplay('common-gen-settings-block', active === 'openai' ? 'none' : 'block');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EmbeddedRuntimeManager} manager
|
||||
* @param {{ panelId: string; parkedSelector: string; pinnedSelectors?: string[]; afterHydrate?: (reason: string) => void }} config
|
||||
*/
|
||||
function registerDrawerParking(manager, { panelId, parkedSelector, pinnedSelectors = [], afterHydrate }) {
|
||||
const host = document.getElementById(panelId);
|
||||
if (!(host instanceof HTMLElement)) {
|
||||
throw new Error(`PanelParking: #${panelId} not found`);
|
||||
}
|
||||
|
||||
const drawer = host.closest('.drawer');
|
||||
if (!(drawer instanceof HTMLElement)) {
|
||||
throw new Error(`PanelParking(${panelId}): .drawer ancestor not found`);
|
||||
}
|
||||
|
||||
const toggle = drawer.querySelector('.drawer-toggle');
|
||||
if (!(toggle instanceof HTMLElement)) {
|
||||
throw new Error(`PanelParking(${panelId}): .drawer-toggle not found`);
|
||||
}
|
||||
|
||||
const parkedRoot = host.querySelector(parkedSelector);
|
||||
if (!(parkedRoot instanceof HTMLElement)) {
|
||||
throw new Error(`PanelParking(${panelId}): parked root not found: ${parkedSelector}`);
|
||||
}
|
||||
|
||||
/** @type {{ park: () => void; restore: () => void } | null} */
|
||||
let pinned = null;
|
||||
if (Array.isArray(pinnedSelectors) && pinnedSelectors.length > 0) {
|
||||
const anchorZone = document.createElement('div');
|
||||
anchorZone.dataset.ttPanelAnchorZone = panelId;
|
||||
anchorZone.style.display = 'none';
|
||||
host.appendChild(anchorZone);
|
||||
|
||||
/** @type {Array<{ selector: string; start: Comment; end: Comment; slot: HTMLElement }>} */
|
||||
const ranges = [];
|
||||
for (const rawSelector of pinnedSelectors) {
|
||||
const selector = String(rawSelector || '').trim();
|
||||
if (!selector) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const anchor = parkedRoot.querySelector(selector);
|
||||
if (!(anchor instanceof Element)) {
|
||||
throw new Error(`PanelParking(${panelId}): pinned selector not found in parked root: ${selector}`);
|
||||
}
|
||||
|
||||
const parent = anchor.parentNode;
|
||||
if (!parent) {
|
||||
throw new Error(`PanelParking(${panelId}): pinned selector has no parent: ${selector}`);
|
||||
}
|
||||
|
||||
const start = document.createComment(`tt:pinned-start:${panelId}:${selector}`);
|
||||
const end = document.createComment(`tt:pinned-end:${panelId}:${selector}`);
|
||||
parent.insertBefore(start, anchor);
|
||||
parent.insertBefore(end, anchor.nextSibling);
|
||||
|
||||
const slot = document.createElement('div');
|
||||
slot.dataset.ttPinnedSelector = selector;
|
||||
anchorZone.appendChild(slot);
|
||||
|
||||
ranges.push({ selector, start, end, slot });
|
||||
}
|
||||
|
||||
pinned = {
|
||||
park: () => {
|
||||
for (const r of ranges) {
|
||||
const parent = r.start.parentNode;
|
||||
if (!parent) {
|
||||
throw new Error(`PanelParking(${panelId}): pinned start lost: ${r.selector}`);
|
||||
}
|
||||
if (r.end.parentNode !== parent) {
|
||||
throw new Error(`PanelParking(${panelId}): pinned range split: ${r.selector}`);
|
||||
}
|
||||
|
||||
while (r.start.nextSibling && r.start.nextSibling !== r.end) {
|
||||
r.slot.appendChild(r.start.nextSibling);
|
||||
}
|
||||
}
|
||||
},
|
||||
restore: () => {
|
||||
for (const r of ranges) {
|
||||
const parent = r.start.parentNode;
|
||||
if (!parent) {
|
||||
throw new Error(`PanelParking(${panelId}): pinned start lost: ${r.selector}`);
|
||||
}
|
||||
if (r.end.parentNode !== parent) {
|
||||
throw new Error(`PanelParking(${panelId}): pinned range split: ${r.selector}`);
|
||||
}
|
||||
|
||||
while (r.slot.firstChild) {
|
||||
parent.insertBefore(r.slot.firstChild, r.end);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const anchor = document.createComment(`tt:panel-park:${panelId}`);
|
||||
const parent = parkedRoot.parentNode;
|
||||
if (!parent) {
|
||||
throw new Error(`PanelParking(${panelId}): parked root has no parent`);
|
||||
}
|
||||
parent.insertBefore(anchor, parkedRoot.nextSibling);
|
||||
|
||||
const slotId = `panel:${panelId}`;
|
||||
|
||||
/** @param {string} reason */
|
||||
const hydrate = (reason) => {
|
||||
if (parkedRoot.isConnected) {
|
||||
if (pinned) {
|
||||
pinned.restore();
|
||||
}
|
||||
if (afterHydrate) {
|
||||
afterHydrate(reason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!anchor.parentNode) {
|
||||
throw new Error(`PanelParking(${panelId}).hydrate(${reason}): anchor lost`);
|
||||
}
|
||||
anchor.parentNode.insertBefore(parkedRoot, anchor);
|
||||
if (pinned) {
|
||||
pinned.restore();
|
||||
}
|
||||
if (afterHydrate) {
|
||||
afterHydrate(reason);
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {string} reason */
|
||||
const dehydrate = (reason) => {
|
||||
if (pinned) {
|
||||
pinned.park();
|
||||
}
|
||||
if (!parkedRoot.isConnected) {
|
||||
return;
|
||||
}
|
||||
parkedRoot.remove();
|
||||
};
|
||||
|
||||
manager.register({
|
||||
id: slotId,
|
||||
kind: PanelRuntimeKind.DrawerContent,
|
||||
element: host,
|
||||
visibilityMode: 'manual',
|
||||
initialVisible: isDrawerOpen(host),
|
||||
hydrate,
|
||||
dehydrate,
|
||||
});
|
||||
|
||||
const onToggleClickCapture = () => {
|
||||
manager.setVisible(slotId, true);
|
||||
manager.reconcile();
|
||||
};
|
||||
toggle.addEventListener('click', onToggleClickCapture, true);
|
||||
|
||||
const classObserver = new MutationObserver(() => {
|
||||
const open = isDrawerOpen(host);
|
||||
manager.setVisible(slotId, open);
|
||||
if (open) {
|
||||
manager.reconcile();
|
||||
}
|
||||
});
|
||||
|
||||
classObserver.observe(host, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return {
|
||||
drawerId: typeof drawer.id === 'string' ? drawer.id : '',
|
||||
slotId,
|
||||
dispose: () => {
|
||||
classObserver.disconnect();
|
||||
toggle.removeEventListener('click', onToggleClickCapture, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 0: Park the heaviest closed drawers under #top-settings-holder by
|
||||
* detaching their heavy subtrees from the main document tree.
|
||||
*
|
||||
* Controlled by `tauritavern-settings.panel_runtime_profile` (default: compat).
|
||||
*
|
||||
* @param {{ manager: EmbeddedRuntimeManager }} options
|
||||
*/
|
||||
export function installTopSettingsPanelParking({ manager }) {
|
||||
const leftNavPinnedSelectors = manager.profile === 'compat'
|
||||
? ['#openai_preset_import_file', '#completion_prompt_manager']
|
||||
: [];
|
||||
|
||||
const registrations = [
|
||||
registerDrawerParking(manager, {
|
||||
panelId: 'left-nav-panel',
|
||||
parkedSelector: '.scrollableInner',
|
||||
pinnedSelectors: leftNavPinnedSelectors,
|
||||
afterHydrate: () => syncLeftNavMainApiUi(),
|
||||
}),
|
||||
registerDrawerParking(manager, { panelId: 'AdvancedFormatting', parkedSelector: '.flex-container.spaceEvenly' }),
|
||||
registerDrawerParking(manager, { panelId: 'WorldInfo', parkedSelector: '#wi-holder' }),
|
||||
registerDrawerParking(manager, { panelId: 'user-settings-block', parkedSelector: '#user-settings-block-content' }),
|
||||
registerDrawerParking(manager, { panelId: 'Backgrounds', parkedSelector: '#bg_tabs' }),
|
||||
registerDrawerParking(manager, { panelId: 'PersonaManagement', parkedSelector: '#persona-management-block' }),
|
||||
];
|
||||
|
||||
/** @type {Array<[string, string]>} */
|
||||
const entries = [];
|
||||
for (const r of registrations) {
|
||||
if (r.drawerId) {
|
||||
entries.push([r.drawerId, r.slotId]);
|
||||
}
|
||||
}
|
||||
const slotByDrawerId = new Map(entries);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const opener = target.closest('.drawer-opener');
|
||||
if (!(opener instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawerId = String(opener.getAttribute('data-target') || '').trim();
|
||||
const slotId = drawerId ? slotByDrawerId.get(drawerId) : null;
|
||||
if (!slotId) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.setVisible(slotId, true);
|
||||
manager.reconcile();
|
||||
}, true);
|
||||
|
||||
manager.reconcile();
|
||||
}
|
||||
5
src/tauri/main/bootstrap.js
vendored
5
src/tauri/main/bootstrap.js
vendored
|
|
@ -21,6 +21,7 @@ import {
|
|||
toUrl,
|
||||
} from './http-utils.js';
|
||||
import { registerRoutes } from './routes/index.js';
|
||||
import { preinstallPanelRuntime } from './services/panel-runtime/preinstall.js';
|
||||
|
||||
let bootstrapped = false;
|
||||
const HOST_ABI_VERSION = 1;
|
||||
|
|
@ -318,8 +319,7 @@ export function bootstrapTauriMain() {
|
|||
interceptors.patchFetch();
|
||||
interceptors.patchJQueryAjax();
|
||||
downloadBridge.patchWindow();
|
||||
installSameOriginWindowPatches(interceptors, downloadBridge);
|
||||
|
||||
installSameOriginWindowPatches(interceptors, downloadBridge); preinstallPanelRuntime();
|
||||
const readyPromise = initializeTauriIntegration(
|
||||
context,
|
||||
interceptors,
|
||||
|
|
@ -338,6 +338,7 @@ export function bootstrapTauriMain() {
|
|||
console.warn('TauriTavern: Failed to load LAN sync panel:', error);
|
||||
}));
|
||||
void readyPromise.then(() => import('./services/embedded-runtime/install.js').then(({ installEmbeddedRuntime }) => installEmbeddedRuntime()));
|
||||
void readyPromise.then(() => import('./services/panel-runtime/install.js').then(({ installPanelRuntime }) => installPanelRuntime()));
|
||||
|
||||
if (perfEnabled) {
|
||||
readyPromise
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const GLOBAL_KEY = '__TAURITAVERN_PERF__';
|
||||
const EMBEDDED_RUNTIME_KEY = '__TAURITAVERN_EMBEDDED_RUNTIME__';
|
||||
const PANEL_RUNTIME_KEY = '__TAURITAVERN_PANEL_RUNTIME__';
|
||||
const STORAGE_ENABLED_KEY = 'tt:perf';
|
||||
const STORAGE_POSITION_KEY = 'tt:perf:pos';
|
||||
|
||||
|
|
@ -599,6 +600,19 @@ function createPerfController(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
function getPanelRuntimeSample() {
|
||||
try {
|
||||
const runtime = globalThis[PANEL_RUNTIME_KEY];
|
||||
if (!runtime || typeof runtime.getPerfSnapshot !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runtime.getPerfSnapshot();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getHeapSample() {
|
||||
try {
|
||||
const mem = globalThis.performance?.memory;
|
||||
|
|
@ -660,6 +674,138 @@ function createPerfController(options = {}) {
|
|||
return items.slice(0, Math.max(0, limit));
|
||||
}
|
||||
|
||||
function cssEscape(raw) {
|
||||
if (typeof raw !== 'string') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return globalThis.CSS?.escape ? globalThis.CSS.escape(raw) : raw.replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} el
|
||||
*/
|
||||
function buildSelectorPath(el) {
|
||||
const parts = [];
|
||||
let cur = el;
|
||||
let hops = 0;
|
||||
while (cur && hops < 6) {
|
||||
const tag = String(cur.tagName || '').toLowerCase();
|
||||
const id = typeof cur.id === 'string' && cur.id ? `#${cssEscape(cur.id)}` : '';
|
||||
const classes = cur.classList && cur.classList.length
|
||||
? `.${[...cur.classList].slice(0, 2).map(cssEscape).join('.')}`
|
||||
: '';
|
||||
parts.unshift(`${tag}${id}${classes}`);
|
||||
|
||||
if (id) {
|
||||
break;
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
hops += 1;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {Element | null} options.root
|
||||
* @param {number} options.topN
|
||||
* @param {number} options.minDescendants
|
||||
*/
|
||||
function computeTopDomSubtrees({ root, topN, minDescendants }) {
|
||||
const host = root || document.body;
|
||||
if (!(host instanceof Element)) {
|
||||
throw new Error('domTop(): root must be an Element');
|
||||
}
|
||||
|
||||
const n = clampInt(topN, 1, 200, 12);
|
||||
const min = clampInt(minDescendants, 0, 1_000_000, 0);
|
||||
|
||||
/** @type {Array<{ el: Element; depth: number }>} */
|
||||
const nodes = [];
|
||||
/** @type {Array<{ el: Element; depth: number }>} */
|
||||
const stack = [{ el: host, depth: 0 }];
|
||||
|
||||
while (stack.length) {
|
||||
const item = stack.pop();
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
nodes.push(item);
|
||||
const children = item.el.children;
|
||||
for (let i = children.length - 1; i >= 0; i -= 1) {
|
||||
const child = children[i];
|
||||
if (child instanceof Element) {
|
||||
stack.push({ el: child, depth: item.depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {WeakMap<Element, number>} */
|
||||
const subtreeSizes = new WeakMap();
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
||||
const el = nodes[i]?.el;
|
||||
if (!el) {
|
||||
continue;
|
||||
}
|
||||
let size = 1;
|
||||
const children = el.children;
|
||||
for (let j = 0; j < children.length; j += 1) {
|
||||
const child = children[j];
|
||||
if (!(child instanceof Element)) {
|
||||
continue;
|
||||
}
|
||||
size += subtreeSizes.get(child) || 1;
|
||||
}
|
||||
subtreeSizes.set(el, size);
|
||||
}
|
||||
|
||||
/** @type {Array<{ selector: string; tag: string; depth: number; directChildren: number; descendants: number; text: string }>} */
|
||||
const top = [];
|
||||
|
||||
for (const { el, depth } of nodes) {
|
||||
const size = subtreeSizes.get(el) || 1;
|
||||
const descendants = size - 1;
|
||||
if (descendants < min) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top.length < n) {
|
||||
top.push({
|
||||
selector: buildSelectorPath(el),
|
||||
tag: String(el.tagName || '').toLowerCase(),
|
||||
depth,
|
||||
directChildren: el.children.length,
|
||||
descendants,
|
||||
text: String(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80),
|
||||
});
|
||||
top.sort((a, b) => b.descendants - a.descendants);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tail = top[top.length - 1];
|
||||
if (tail && descendants <= tail.descendants) {
|
||||
continue;
|
||||
}
|
||||
|
||||
top.pop();
|
||||
top.push({
|
||||
selector: buildSelectorPath(el),
|
||||
tag: String(el.tagName || '').toLowerCase(),
|
||||
depth,
|
||||
directChildren: el.children.length,
|
||||
descendants,
|
||||
text: String(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80),
|
||||
});
|
||||
top.sort((a, b) => b.descendants - a.descendants);
|
||||
}
|
||||
|
||||
return top;
|
||||
}
|
||||
|
||||
function clampHudPosition(x, y) {
|
||||
const vw = Number(globalThis.innerWidth) || 0;
|
||||
const vh = Number(globalThis.innerHeight) || 0;
|
||||
|
|
@ -879,6 +1025,7 @@ function createPerfController(options = {}) {
|
|||
const frameStats = computeFrameStats();
|
||||
const dom = getDomSample();
|
||||
const embeddedRuntime = getEmbeddedRuntimeSample();
|
||||
const panelRuntime = getPanelRuntimeSample();
|
||||
const heap = getHeapSample();
|
||||
const lagStats = computeEventLoopLagStats();
|
||||
|
||||
|
|
@ -928,6 +1075,9 @@ function createPerfController(options = {}) {
|
|||
if (embeddedRuntime) {
|
||||
lines.push(`Runtime: active ${embeddedRuntime.active}/${embeddedRuntime.registered} iframes ${embeddedRuntime.activeIframes} parked ${embeddedRuntime.parked}`);
|
||||
}
|
||||
if (panelRuntime) {
|
||||
lines.push(`Panels: active ${panelRuntime.active}/${panelRuntime.registered} parked ${panelRuntime.parked}`);
|
||||
}
|
||||
if (heap) {
|
||||
const limitSuffix = heap.limit ? ` (limit ${formatMB(heap.limit)})` : '';
|
||||
lines.push(`Heap: ${formatMB(heap.used)} / ${formatMB(heap.total)}${limitSuffix}`);
|
||||
|
|
@ -970,6 +1120,7 @@ function createPerfController(options = {}) {
|
|||
lines.push('');
|
||||
lines.push(`timeOrigin: ${Math.round(state.timeOrigin)} now(): ${Math.round(safeNow())}`);
|
||||
lines.push('Commands: __TAURITAVERN_PERF__.exportJson() / downloadReport() / copyReport() / saveReport()');
|
||||
lines.push('DOM report: __TAURITAVERN_PERF__.domTop()');
|
||||
lines.push('Auto-enable: localStorage tt:perf=1');
|
||||
}
|
||||
|
||||
|
|
@ -1164,6 +1315,7 @@ function createPerfController(options = {}) {
|
|||
const frameStats = computeFrameStats();
|
||||
const dom = getDomSample();
|
||||
const embeddedRuntime = getEmbeddedRuntimeSample();
|
||||
const panelRuntime = getPanelRuntimeSample();
|
||||
const heap = getHeapSample();
|
||||
const eventLoopLag = computeEventLoopLagStats();
|
||||
|
||||
|
|
@ -1218,6 +1370,7 @@ function createPerfController(options = {}) {
|
|||
},
|
||||
dom,
|
||||
embeddedRuntime,
|
||||
panelRuntime,
|
||||
heap,
|
||||
init: {
|
||||
totalMs: readMeasureDuration('tt:init:total'),
|
||||
|
|
@ -1418,6 +1571,18 @@ function createPerfController(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
function domTop(options = {}) {
|
||||
const rootSelector = typeof options.rootSelector === 'string' ? options.rootSelector : '';
|
||||
const root = rootSelector ? document.querySelector(rootSelector) : document.body;
|
||||
const top = computeTopDomSubtrees({
|
||||
root: root instanceof Element ? root : document.body,
|
||||
topN: options.topN ?? 12,
|
||||
minDescendants: options.minDescendants ?? 0,
|
||||
});
|
||||
console.table(top);
|
||||
return top;
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
get enabled() {
|
||||
|
|
@ -1435,6 +1600,7 @@ function createPerfController(options = {}) {
|
|||
copyReport,
|
||||
downloadReport,
|
||||
saveReport,
|
||||
domTop,
|
||||
attachContext,
|
||||
setPosition: (x, y) => setHudPosition(x, y, { persist: true }),
|
||||
resetPosition: resetHudPosition,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export function createEmbeddedRuntimeManager({ profile, now = () => Date.now(),
|
|||
const slots = new Map();
|
||||
|
||||
let reconcilePending = false;
|
||||
let reconcileSeq = 0;
|
||||
|
||||
const counters = {
|
||||
hydrate: 0,
|
||||
|
|
@ -100,6 +101,18 @@ export function createEmbeddedRuntimeManager({ profile, now = () => Date.now(),
|
|||
threshold: normalizedProfile.threshold,
|
||||
});
|
||||
|
||||
/**
|
||||
* Cancels a pending requestAnimationFrame reconcile, if any.
|
||||
*/
|
||||
function cancelPendingReconcile() {
|
||||
if (!reconcilePending) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconcilePending = false;
|
||||
reconcileSeq += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reason
|
||||
*/
|
||||
|
|
@ -108,7 +121,12 @@ export function createEmbeddedRuntimeManager({ profile, now = () => Date.now(),
|
|||
return;
|
||||
}
|
||||
reconcilePending = true;
|
||||
const seq = (reconcileSeq += 1);
|
||||
requestAnimationFrame(() => {
|
||||
if (seq !== reconcileSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconcilePending = false;
|
||||
reconcile(reason);
|
||||
});
|
||||
|
|
@ -415,7 +433,10 @@ export function createEmbeddedRuntimeManager({ profile, now = () => Date.now(),
|
|||
touch,
|
||||
invalidate,
|
||||
setVisible,
|
||||
reconcile: () => reconcile('manual'),
|
||||
reconcile: () => {
|
||||
cancelPendingReconcile();
|
||||
reconcile('manual');
|
||||
},
|
||||
getPerfSnapshot,
|
||||
get profile() {
|
||||
return normalizedProfile.name;
|
||||
|
|
|
|||
38
src/tauri/main/services/panel-runtime/install.js
Normal file
38
src/tauri/main/services/panel-runtime/install.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// @ts-check
|
||||
|
||||
import { eventSource, event_types } from '../../../../scripts/events.js';
|
||||
import { getTauriTavernSettings } from '../../../../tauri-bridge.js';
|
||||
import { activateApiConnectionsSubtreeGates } from '../../adapters/panel-runtime/api-connections-subtree-gates.js';
|
||||
import { installExtensionsSubtreeGates } from '../../adapters/panel-runtime/extensions-subtree-gates.js';
|
||||
import { installTopSettingsPanelParking } from '../../adapters/panel-runtime/top-settings-panel-parking.js';
|
||||
import { createPanelRuntimeService } from './panel-runtime-service.js';
|
||||
import { validatePanelRuntimeInvariants } from './validate.js';
|
||||
|
||||
// Keep in sync with:
|
||||
// - src/tauri/main/services/panel-runtime/preinstall.js
|
||||
// - src/scripts/tauri/sync/sync-panel.js
|
||||
const PANEL_RUNTIME_PROFILE_STORAGE_KEY = 'tt:panelRuntimeProfile';
|
||||
|
||||
export function installPanelRuntime() {
|
||||
const ready = getTauriTavernSettings().then((settings) => {
|
||||
const profileName = String(settings.panel_runtime_profile || '').trim();
|
||||
localStorage.setItem(PANEL_RUNTIME_PROFILE_STORAGE_KEY, profileName);
|
||||
|
||||
if (profileName === 'off') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const service = createPanelRuntimeService({ profileName });
|
||||
|
||||
eventSource.on(event_types.APP_READY, () => {
|
||||
activateApiConnectionsSubtreeGates({ manager: service.manager });
|
||||
installTopSettingsPanelParking({ manager: service.manager });
|
||||
installExtensionsSubtreeGates({ manager: service.manager });
|
||||
validatePanelRuntimeInvariants({ profileName: service.manager.profile });
|
||||
});
|
||||
|
||||
return service;
|
||||
});
|
||||
|
||||
return { ready };
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
export const PanelRuntimeKind = Object.freeze({
|
||||
DrawerContent: 'drawer-content',
|
||||
SubtreeGate: 'subtree-gate',
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// @ts-check
|
||||
|
||||
import { PanelRuntimeKind } from './panel-runtime-kinds.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../embedded-runtime/types.js').EmbeddedRuntimeProfile} EmbeddedRuntimeProfile
|
||||
*/
|
||||
|
||||
const COMPAT_PROFILE = Object.freeze({
|
||||
name: 'compat',
|
||||
maxActiveWeight: 0,
|
||||
maxActiveIframes: 0,
|
||||
maxActiveSlots: 0,
|
||||
maxSoftParkedIframes: 0,
|
||||
softParkTtlMs: 0,
|
||||
parkWhenHiddenKinds: Object.freeze([
|
||||
PanelRuntimeKind.DrawerContent,
|
||||
PanelRuntimeKind.SubtreeGate,
|
||||
]),
|
||||
rootMargin: '0px',
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
const AGGRESSIVE_PROFILE = Object.freeze({
|
||||
name: 'aggressive',
|
||||
maxActiveWeight: 0,
|
||||
maxActiveIframes: 0,
|
||||
maxActiveSlots: 0,
|
||||
maxSoftParkedIframes: 0,
|
||||
softParkTtlMs: 0,
|
||||
parkWhenHiddenKinds: Object.freeze([
|
||||
PanelRuntimeKind.DrawerContent,
|
||||
PanelRuntimeKind.SubtreeGate,
|
||||
]),
|
||||
rootMargin: '0px',
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
/** @type {Readonly<Record<string, EmbeddedRuntimeProfile>>} */
|
||||
export const PANEL_RUNTIME_PROFILES = Object.freeze({
|
||||
compat: COMPAT_PROFILE,
|
||||
aggressive: AGGRESSIVE_PROFILE,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} profileName
|
||||
*/
|
||||
export function resolvePanelRuntimeProfile(profileName) {
|
||||
const name = String(profileName || '').trim();
|
||||
if (!name) {
|
||||
throw new Error('PanelRuntime: profile name is required');
|
||||
}
|
||||
|
||||
const profile = PANEL_RUNTIME_PROFILES[name];
|
||||
if (!profile) {
|
||||
throw new Error(`PanelRuntime: unknown profile '${name}'`);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// @ts-check
|
||||
|
||||
import { createEmbeddedRuntimeManager } from '../embedded-runtime/embedded-runtime-manager.js';
|
||||
import { resolvePanelRuntimeProfile } from './panel-runtime-profiles.js';
|
||||
|
||||
const GLOBAL_KEY = '__TAURITAVERN_PANEL_RUNTIME__';
|
||||
|
||||
/**
|
||||
* @param {{ profileName: string }} options
|
||||
*/
|
||||
export function createPanelRuntimeService({ profileName }) {
|
||||
const profile = resolvePanelRuntimeProfile(profileName);
|
||||
const manager = createEmbeddedRuntimeManager({
|
||||
profile,
|
||||
now: () => globalThis.performance?.now?.() ?? Date.now(),
|
||||
root: null,
|
||||
});
|
||||
|
||||
/** @type {any} */ (globalThis)[GLOBAL_KEY] = manager;
|
||||
|
||||
return {
|
||||
profile,
|
||||
manager,
|
||||
};
|
||||
}
|
||||
22
src/tauri/main/services/panel-runtime/preinstall.js
Normal file
22
src/tauri/main/services/panel-runtime/preinstall.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// @ts-check
|
||||
|
||||
import { preinstallApiConnectionsSubtreeGates } from '../../adapters/panel-runtime/api-connections-subtree-gates.js';
|
||||
|
||||
// Keep in sync with:
|
||||
// - src/tauri/main/services/panel-runtime/install.js
|
||||
// - src/scripts/tauri/sync/sync-panel.js
|
||||
//
|
||||
// Rationale: `panel_runtime_profile` is persisted in Tauri settings (async to load),
|
||||
// but some panel-runtime hooks must be installed during bootstrap to preserve
|
||||
// handler ordering for jQuery `.trigger('change')`. We mirror the last known
|
||||
// profile into localStorage so bootstrap can synchronously honor `off`.
|
||||
const PANEL_RUNTIME_PROFILE_STORAGE_KEY = 'tt:panelRuntimeProfile';
|
||||
|
||||
export function preinstallPanelRuntime() {
|
||||
const profile = String(localStorage.getItem(PANEL_RUNTIME_PROFILE_STORAGE_KEY) || '').trim();
|
||||
if (profile === 'off') {
|
||||
return;
|
||||
}
|
||||
|
||||
preinstallApiConnectionsSubtreeGates();
|
||||
}
|
||||
65
src/tauri/main/services/panel-runtime/validate.js
Normal file
65
src/tauri/main/services/panel-runtime/validate.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function mustGetConnectedElementById(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
throw new Error(`PanelRuntime validate: #${id} not found`);
|
||||
}
|
||||
if (!el.isConnected) {
|
||||
throw new Error(`PanelRuntime validate: #${id} is not connected`);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ profileName: string }} options
|
||||
*/
|
||||
export function validatePanelRuntimeInvariants({ profileName }) {
|
||||
const profile = String(profileName || '').trim();
|
||||
if (!profile) {
|
||||
throw new Error('PanelRuntime validate: profileName is required');
|
||||
}
|
||||
if (profile !== 'compat' && profile !== 'aggressive') {
|
||||
throw new Error(`PanelRuntime validate: unknown profile '${profile}'`);
|
||||
}
|
||||
|
||||
// Extensions mount points.
|
||||
mustGetConnectedElementById('rm_extensions_block');
|
||||
mustGetConnectedElementById('extensions_settings');
|
||||
mustGetConnectedElementById('regex_container');
|
||||
mustGetConnectedElementById('qr_container');
|
||||
|
||||
// Anchor-zone compatibility: third-party scripts expect these anchors to be selectable even when parked.
|
||||
if (profile === 'compat') {
|
||||
mustGetConnectedElementById('openai_preset_import_file');
|
||||
mustGetConnectedElementById('completion_prompt_manager');
|
||||
}
|
||||
|
||||
// API connections control surface (only meaningful when left-nav is hydrated).
|
||||
const leftNavScrollable = document.querySelector('#left-nav-panel .scrollableInner');
|
||||
const isLeftNavHydrated = leftNavScrollable instanceof HTMLElement && leftNavScrollable.isConnected;
|
||||
if (!isLeftNavHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainApiEl = mustGetConnectedElementById('main_api');
|
||||
if (!(mainApiEl instanceof HTMLSelectElement)) {
|
||||
throw new Error('PanelRuntime validate: #main_api is not a <select>');
|
||||
}
|
||||
mustGetConnectedElementById('kobold_horde');
|
||||
mustGetConnectedElementById('kobold_api');
|
||||
mustGetConnectedElementById('novel_api');
|
||||
mustGetConnectedElementById('textgenerationwebui_api');
|
||||
mustGetConnectedElementById('openai_api');
|
||||
|
||||
if (String(mainApiEl.value || '').trim() === 'openai') {
|
||||
mustGetConnectedElementById('chat_completion_source');
|
||||
}
|
||||
if (String(mainApiEl.value || '').trim() === 'textgenerationwebui') {
|
||||
mustGetConnectedElementById('textgen_type');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue