0 directories, 9 files

util

Home / tinai / util
/**
 * Utilities for populating settings form controls and synchronizing model option values.
 */

/**
 * Formats a thinking level value into a human-readable display label.
 * @param {string|null} level - Raw thinking level value.
 * @returns {string} Formatted label.
 */
export function formatThinkingLevelLabel(level) {
	if (level === null || level === undefined || level === '' || level === 'none') {
		return 'None';
	}
	const str = String(level);
	switch (str.toUpperCase()) {
		case 'MINIMAL':
			return 'Minimal';
		case 'LOW':
			return 'Low';
		case 'MEDIUM':
			return 'Medium';
		case 'HIGH':
			return 'High';
		case 'XHIGH':
			return 'Extra High';
		case 'MAX':
			return 'Max';
		default:
			return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
	}
}

/**
 * Formats a version date string (e.g. '20260909' or '2026-09-09') into a readable date (e.g. 'September 9, 2026').
 * @param {string|number|null} dateStr - Raw date string or number.
 * @returns {string} Formatted date string.
 */
export function formatVersionDate(dateStr) {
	if (!dateStr) return '';
	const str = String(dateStr).trim();
	if (/^\d{8}$/.test(str)) {
		const year = parseInt(str.slice(0, 4), 10);
		const month = parseInt(str.slice(4, 6), 10) - 1;
		const day = parseInt(str.slice(6, 8), 10);
		const d = new Date(year, month, day);
		return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
	}
	const d = new Date(str);
	if (!isNaN(d.getTime())) {
		return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
	}
	return str;
}

/**
 * Formats a version number into standard display string (e.g. 0.1 -> 0.10).
 * @param {number|string|null} ver - Raw version number.
 * @returns {string} Formatted version string.
 */
export function formatVersionNumber(ver) {
	if (ver === null || ver === undefined) return '';
	if (typeof ver === 'number') {
		if (ver < 1) {
			return ver.toFixed(2);
		}
		return String(ver);
	}
	return String(ver);
}

/**
 * Formats the full version header title
 * @param {Object} versionData - Version data object.
 * @returns {string}
 */
export function formatVersionHeader(versionData) {
	if (!versionData) return 'TinAI';
	const verNum = formatVersionNumber(versionData['version-number']);
	const name = versionData['version-name'] || '';
	const formattedDate = formatVersionDate(versionData['version-date']);
	return `TinAI v${verNum} "${name}", released ${formattedDate}`;
}

/**
 * Escapes text for HTML and converts inline markdown backticks to <code> tags.
 * @param {string} text - Changelog text item.
 * @returns {string}
 */
export function formatChangelogItem(text) {
	if (!text) return '';
	const escaped = text
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;');
	return escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
}

/**
 * Populates a select dropdown with utility models.
 * @param {HTMLSelectElement|null} selectEl - Target select element.
 * @param {object} utilityModels - Map of utility model definitions.
 * @param {string|null} [selectedKey='gemini-3.1-fl'] - Key of the utility model to select.
 */
export function populateUtilityModelDropdown(selectEl, utilityModels, selectedKey = 'gemini-3.1-fl') {
	if (!selectEl) return;
	selectEl.innerHTML = '';
	if (!utilityModels || typeof utilityModels !== 'object') return;

	for (const key in utilityModels) {
		if (Object.prototype.hasOwnProperty.call(utilityModels, key)) {
			const item = utilityModels[key];
			const option = document.createElement('option');
			option.value = key;
			option.textContent = item.name || key;
			if (key === selectedKey) {
				option.selected = true;
			}
			selectEl.appendChild(option);
		}
	}
}

/**
 * Populates a select dropdown with presets.
 * @param {HTMLSelectElement|null} selectEl - Target select element.
 * @param {object} presets - Map of preset definitions.
 * @param {string|null} [selectedKey=''] - Key of the preset to select.
 */
export function populatePresetDropdown(selectEl, presets, selectedKey = '') {
	if (!selectEl) return;
	selectEl.innerHTML = '';
	if (!presets || typeof presets !== 'object') return;

	const customOption = document.createElement('option');
	customOption.value = '';
	customOption.textContent = '(Custom / None)';
	selectEl.appendChild(customOption);

	for (const key in presets) {
		if (Object.prototype.hasOwnProperty.call(presets, key)) {
			const preset = presets[key];
			const option = document.createElement('option');
			option.value = key;
			option.textContent = `${preset.name || key} (${preset.description || ''})`;
			if (selectedKey && key === selectedKey) {
				option.selected = true;
			}
			selectEl.appendChild(option);
		}
	}
	if (!selectedKey) {
		customOption.selected = true;
	}
}

/**
 * Populates a select dropdown with model families.
 * @param {HTMLSelectElement|null} selectEl - Target select element.
 * @param {object} familiesData - Map of family definitions.
 * @param {string|null} [selectedFamily='gemini'] - Currently selected family key.
 */
export function populateFamilyDropdown(selectEl, familiesData, selectedFamily = 'gemini') {
	if (!selectEl) return;
	selectEl.innerHTML = '';
	if (!familiesData || typeof familiesData !== 'object') return;

	for (const key in familiesData) {
		if (Object.prototype.hasOwnProperty.call(familiesData, key)) {
			const family = familiesData[key];
			const option = document.createElement('option');
			option.value = key;
			option.textContent = family.family || key;
			if (key === selectedFamily) {
				option.selected = true;
			}
			selectEl.appendChild(option);
		}
	}
}

/**
 * Populates a select dropdown with model versions within a family.
 * @param {HTMLSelectElement|null} selectEl - Target select element.
 * @param {Array<object>} modelsList - Array of model definitions for the family.
 * @param {string|null} [selectedModel=''] - Selected API model string.
 */
export function populateModelVersionDropdown(selectEl, modelsList, selectedModel = '') {
	if (!selectEl) return;
	selectEl.innerHTML = '';
	if (!Array.isArray(modelsList)) return;

	modelsList.forEach((m) => {
		const option = document.createElement('option');
		option.value = m.model;
		option.textContent = m.suffix || m.model;
		if (m.model === selectedModel) {
			option.selected = true;
		}
		selectEl.appendChild(option);
	});
}

/**
 * Populates a select dropdown with thinking levels for a model.
 * Disables the dropdown when the model does not support any selectable thinking modes.
 * @param {HTMLSelectElement|null} selectEl - Target select element.
 * @param {Array<string|null>} thinkingModes - Supported thinking mode values.
 * @param {string|null} [selectedLevel=null] - Currently selected thinking level.
 */
export function populateThinkingLevelDropdown(selectEl, thinkingModes, selectedLevel = null) {
	if (!selectEl) return;
	selectEl.innerHTML = '';
	if (!Array.isArray(thinkingModes) || thinkingModes.length === 0) {
		selectEl.disabled = true;
		return;
	}

	const hasSelectableModes = thinkingModes.some(m => m !== null && m !== undefined && m !== '');
	const normSelected = (selectedLevel === null || selectedLevel === undefined || selectedLevel === '' || selectedLevel === 'none') ? '' : String(selectedLevel);

	thinkingModes.forEach((mode) => {
		const val = (mode === null || mode === undefined || mode === '' || mode === 'none') ? '' : String(mode);
		const option = document.createElement('option');
		option.value = val;
		option.textContent = formatThinkingLevelLabel(mode);
		if (val === normSelected) {
			option.selected = true;
		}
		selectEl.appendChild(option);
	});

	selectEl.disabled = !hasSelectableModes;
}

/**
 * Finds a matching preset key from the given model settings.
 * @param {object} presets - Presets dictionary.
 * @param {string} familyKey - Model family key (gemini, openai, deepseek, mistral).
 * @param {string} modelId - Model API identifier.
 * @param {string|null} thinkingLevel - Thinking level string.
 * @returns {string} Matching preset key or empty string if none.
 */
export function findMatchingPreset(presets, familyKey, modelId, thinkingLevel) {
	if (!presets || typeof presets !== 'object') return '';
	const normThinking = (thinkingLevel === null || thinkingLevel === undefined || thinkingLevel === '' || thinkingLevel === 'none') ? '' : String(thinkingLevel).toUpperCase();

	for (const key in presets) {
		if (Object.prototype.hasOwnProperty.call(presets, key)) {
			const preset = presets[key];
			const presetProvider = (preset.provider || '').toLowerCase();
			const targetFamily = (familyKey || '').toLowerCase();
			if (presetProvider === targetFamily && preset.model === modelId) {
				const pThinking = (preset.thinking === null || preset.thinking === undefined || preset.thinking === '' || preset.thinking === 'none') ? '' : String(preset.thinking).toUpperCase();
				if (pThinking === normThinking) {
					return key;
				}
			}
		}
	}
	return '';
}

/**
 * Formats the full name for a model: [family] [suffix] [level] (e.g., "Gemini 3.1 Flash Lite Medium").
 * @param {string} familyKey - Family key.
 * @param {string} modelId - API model identifier.
 * @param {string|null} thinkingLevel - Thinking level.
 * @param {object} familiesData - Families data from models.json.
 * @returns {string}
 */
export function formatModelFullName(familyKey, modelId, thinkingLevel, familiesData) {
	const familyObj = familiesData?.[familyKey];
	const familyName = familyObj?.family || (familyKey ? (familyKey.charAt(0).toUpperCase() + familyKey.slice(1)) : '');
	let suffix = modelId;
	if (familyObj?.models) {
		const m = familyObj.models.find(item => item.model === modelId);
		if (m?.suffix) {
			suffix = m.suffix;
		}
	}
	const levelLabel = (thinkingLevel !== null && thinkingLevel !== undefined && thinkingLevel !== '' && thinkingLevel !== 'none')
		? formatThinkingLevelLabel(thinkingLevel)
		: '';

	return [familyName, suffix, levelLabel].filter(Boolean).join(' ');
}

/**
 * Formats the short name for a model chip: [family] [short suffix] (e.g., "Gemini 3.1FL").
 * @param {string} familyKey - Family key.
 * @param {string} modelId - API model identifier.
 * @param {object} familiesData - Families data from models.json.
 * @returns {string}
 */
export function formatModelShortName(familyKey, modelId, familiesData) {
	if (!modelId && !familyKey) return 'Model';

	let familyName = '';
	let shortSuffix = modelId || '';

	if (familiesData && typeof familiesData === 'object') {
		if (familyKey && familiesData[familyKey]) {
			const fam = familiesData[familyKey];
			familyName = fam.family || (familyKey.charAt(0).toUpperCase() + familyKey.slice(1));
			if (fam.models) {
				const m = fam.models.find(item => item.model === modelId || (modelId && item.model.includes(modelId)) || (modelId && modelId.includes(item.model)));
				if (m) {
					shortSuffix = m.short_suffix || m.suffix || shortSuffix;
				}
			}
		} else {
			for (const k in familiesData) {
				const fam = familiesData[k];
				const m = fam?.models?.find(item => item.model === modelId || (modelId && item.model.includes(modelId)) || (modelId && modelId.includes(item.model)));
				if (m) {
					familyName = fam.family || (k.charAt(0).toUpperCase() + k.slice(1));
					shortSuffix = m.short_suffix || m.suffix || shortSuffix;
					break;
				}
			}
		}
	}

	if (!familyName && familyKey) {
		familyName = familyKey.charAt(0).toUpperCase() + familyKey.slice(1);
	}

	return [familyName, shortSuffix].filter(Boolean).join(' ') || modelId || 'Model';
}

/**
 * Populates a select dropdown with options from a models map (legacy fallback).
 * @param {HTMLSelectElement|null} selectEl - Target select element.
 * @param {object} models - Key-value map of model definitions.
 * @param {string|null} [selectedKey=null] - Key of the option to mark selected.
 */
export function populateModelDropdown(selectEl, models, selectedKey = null) {
	if (!selectEl) return;
	selectEl.innerHTML = '';
	if (!models || typeof models !== 'object') return;

	for (const key in models) {
		if (Object.prototype.hasOwnProperty.call(models, key)) {
			const model = models[key];
			const option = document.createElement('option');
			option.value = key;
			option.textContent = `${model.name || key} (${model.description || ''})`;
			if (selectedKey && key === selectedKey) {
				option.selected = true;
			}
			selectEl.appendChild(option);
		}
	}
}

/**
 * Resolves [rate_input, rate_output] per 1M tokens for a given model identifier.
 * @param {string} model - Model identifier.
 * @returns {Array<number>} [rateInput, rateOutput]
 */
export function getModelRates(model) {
	if (!model) return [0.30, 2.50];
	const m = String(model).toLowerCase();

	if (m.includes('codestral')) return [0.30, 0.90];
	if (m.includes('nemo')) return [0.15, 0.15];
	if (m.includes('ministral') && m.includes('3b')) return [0.10, 0.10];
	if (m.includes('ministral') && m.includes('8b')) return [0.15, 0.15];
	if (m.includes('ministral') && m.includes('14b')) return [0.20, 0.20];
	if (m.includes('mistral') && m.includes('small')) return [0.15, 0.60];
	if (m.includes('mistral') && m.includes('medium')) return [1.50, 7.50];
	if (m.includes('mistral') && m.includes('large')) return [0.50, 1.50];
	if (m.includes('glm-5.3-flashx')) return [0.37, 1.25];
	if (m.includes('glm-5.3-flash')) return [0.15, 0.50];
	if (m.includes('glm-5.3') || m.includes('glm-5.2') || m.includes('glm-5.1')) return [1.40, 4.40];
	if (m.includes('glm')) return [0.60, 1.80];
	if (m.includes('gpt-6') && m.includes('astra')) return [10.00, 50.00];
	if (m.includes('gpt-5.6') && m.includes('sol')) return [5.00, 30.00];
	if (m.includes('gpt-5.6') && m.includes('terra')) return [2.00, 12.00];
	if (m.includes('gpt-5.6') && m.includes('luna')) return [0.20, 1.20];
	if (m.includes('gpt-5.4') && m.includes('mini')) return [0.75, 4.50];
	if (m.includes('gpt-5.4') && m.includes('nano')) return [0.20, 1.25];
	if (m.includes('deepseek') && m.includes('pro')) return [1.32, 3.96];
	if (m.includes('deepseek') && m.includes('flash')) return [0.44, 1.32];
	if (m.includes('claude-opus-5-5')) return [4.00, 20.00];
	if (m.includes('claude-opus')) return [5.00, 25.00];
	if (m.includes('claude-sonnet-5')) return [2.00, 10.00];
	if (m.includes('claude-sonnet')) return [3.00, 15.00];
	if (m.includes('claude')) return [1.00, 5.00];
	if (m.includes('3.8') && m.includes('flash')) return [0.75, 3.75];
	if (m.includes('3.7') && m.includes('flash')) return [1.50, 7.50];
	if (m.includes('3.6') && m.includes('flash')) return [1.50, 7.50];
	if (m.includes('3.5') && m.includes('flash-lite')) return [0.30, 2.50];
	if (m.includes('3.5') && m.includes('flash')) return [1.50, 9.00];
	if (m.includes('3.1') && m.includes('flash-lite')) return [0.25, 1.50];
	if (m.includes('3.1') && m.includes('pro')) return [2.00, 12.00];
	if (m.includes('3') && m.includes('flash')) return [0.50, 3.00];
	if (m.includes('2.5') && m.includes('flash-lite')) return [0.10, 0.40];
	if (m.includes('2.5') && m.includes('flash')) return [0.30, 2.50];
	if (m.includes('2.5') && m.includes('pro')) return [1.25, 10.00];

	return [1.00, 5.00];
}

/**
 * Returns estimated base prompt, reply, and thought token counts for a verbosity and thinking level.
 * @param {string} verbosity - minimal, standard, thorough, detailed/verbose.
 * @param {string|null} thinkingLevel - Thinking mode/level string.
 * @returns {{inputTokens: number, replyTokens: number, thoughtTokens: number}}
 */
export function getEstimatedTokens(verbosity, thinkingLevel) {
	const verb = String(verbosity || 'standard').toLowerCase();
	let inputTokens = 400;
	let replyTokens = 500;

	if (verb === 'minimal') {
		inputTokens = 250;
		replyTokens = 150;
	} else if (verb === 'standard') {
		inputTokens = 400;
		replyTokens = 500;
	} else if (verb === 'thorough') {
		inputTokens = 600;
		replyTokens = 1200;
	} else if (verb === 'detailed' || verb === 'verbose') {
		inputTokens = 800;
		replyTokens = 2500;
	}

	const normLevel = (thinkingLevel === null || thinkingLevel === undefined || thinkingLevel === '' || thinkingLevel === 'none' || thinkingLevel === 'off')
		? 'none'
		: String(thinkingLevel).toLowerCase();

	let thoughtTokens = 0;
	if (normLevel === 'minimal') {
		thoughtTokens = verb === 'minimal' ? 250 : (verb === 'standard' ? 500 : (verb === 'thorough' ? 800 : 1200));
	} else if (normLevel === 'low') {
		thoughtTokens = verb === 'minimal' ? 600 : (verb === 'standard' ? 1200 : (verb === 'thorough' ? 2000 : 3000));
	} else if (normLevel === 'medium') {
		thoughtTokens = verb === 'minimal' ? 1500 : (verb === 'standard' ? 3500 : (verb === 'thorough' ? 5500 : 8000));
	} else if (normLevel === 'high') {
		thoughtTokens = verb === 'minimal' ? 4000 : (verb === 'standard' ? 8000 : (verb === 'thorough' ? 12000 : 16000));
	} else if (normLevel === 'xhigh') {
		thoughtTokens = verb === 'minimal' ? 7000 : (verb === 'standard' ? 14000 : (verb === 'thorough' ? 20000 : 28000));
	} else if (normLevel === 'max') {
		thoughtTokens = verb === 'minimal' ? 10000 : (verb === 'standard' ? 20000 : (verb === 'thorough' ? 30000 : 40000));
	}

	return { inputTokens, replyTokens, thoughtTokens };
}

/**
 * Returns the per-use search cost for a model, mirroring the search rates in api/Costs.php
 * (raw per-use rate x special feature multiplier).
 * @param {string} modelId - Model API identifier.
 * @returns {number} Cost of a single search use.
 */
export function getSearchUseCost(modelId) {
	const m = String(modelId || '').toLowerCase();
	let rate = 0.01;
	if (m.startsWith('gemini-2')) {
		rate = 0.035;
	} else if (m.startsWith('gemini-3')) {
		rate = 0.014;
	}
	return rate * 1.25;
}

/**
 * Calculates the estimated cost for a utility model based on a response length of two average sentences.
 * @param {string} modelId - Model API identifier.
 * @param {string|null} [thinkingLevel=null] - Thinking level.
 * @returns {{total: number, inputTokens: number, replyTokens: number, thoughtTokens: number}}
 */
export function calculateUtilityCost(modelId, thinkingLevel = null) {
	const [rateInput, rateOutput] = getModelRates(modelId);
	const inputTokens = 100;
	const replyTokens = 50; // Two average sentences (~25 tokens each)
	const normLevel = (thinkingLevel === null || thinkingLevel === undefined || thinkingLevel === '' || thinkingLevel === 'none' || thinkingLevel === 'off')
		? 'none'
		: String(thinkingLevel).toLowerCase();
	const thoughtTokens = normLevel === 'minimal' ? 50 : (normLevel === 'low' ? 100 : (normLevel === 'medium' ? 200 : (normLevel === 'high' ? 400 : (normLevel === 'max' ? 800 : 0))));

	const promptCost = (inputTokens / 1000000) * rateInput * 1.3;
	const replyCost = (replyTokens / 1000000) * rateOutput * 1.1;
	const thoughtCost = (thoughtTokens / 1000000) * rateOutput * 1.2;

	const total = promptCost + replyCost + thoughtCost;
	return { total, inputTokens, replyTokens, thoughtTokens };
}

/**
 * Calculates the estimated cost for a given model, verbosity, and thinking level.
 * @param {string} modelId - Model API identifier.
 * @param {string} verbosity - Verbosity level.
 * @param {string|null} thinkingLevel - Thinking level.
 * @returns {{total: number, inputTokens: number, replyTokens: number, thoughtTokens: number}}
 */
export function calculateEstimatedCost(modelId, verbosity, thinkingLevel) {
	const [rateInput, rateOutput] = getModelRates(modelId);
	const { inputTokens, replyTokens, thoughtTokens } = getEstimatedTokens(verbosity, thinkingLevel);

	const promptCost = (inputTokens / 1000000) * rateInput * 1.3;
	const replyCost = (replyTokens / 1000000) * rateOutput * 1.1;
	const thoughtCost = (thoughtTokens / 1000000) * rateOutput * 1.2;

	const total = promptCost + replyCost + thoughtCost;
	return { total, inputTokens, replyTokens, thoughtTokens };
}

/**
 * Formats a currency cost value nicely.
 * @param {number} cost - The cost in dollars.
 * @returns {string} Formatted string (e.g., "$0.00032").
 */
export function formatEstimatedCost(cost) {
	if (cost <= 0) return '$0.00000';
	if (cost < 0.00001) return '<$0.00001';
	if (cost < 0.01) return `$${cost.toFixed(5)}`;
	if (cost < 0.1) return `$${cost.toFixed(4)}`;
	return `$${cost.toFixed(4)}`;
}

/**
 * Generates an HTML table of estimated costs across the 4 verbosities with the selected row highlighted.
 * @param {string} modelId - Selected model ID.
 * @param {string|null} thinkingLevel - Selected thinking level.
 * @param {string} [selectedVerbosity='standard'] - Currently selected verbosity level.
 * @returns {string} HTML table string.
 */
export function renderModelCostTableHtml(modelId, thinkingLevel, selectedVerbosity = 'standard') {
	const verbosities = [
		{ key: 'minimal', label: 'Minimal' },
		{ key: 'standard', label: 'Standard' },
		{ key: 'thorough', label: 'Thorough' },
		{ key: 'detailed', label: 'Detailed' }
	];

	const normVerbosity = String(selectedVerbosity || 'standard').toLowerCase();

	const rows = verbosities.map(v => {
		const res = calculateEstimatedCost(modelId, v.key, thinkingLevel);
		const formattedCost = formatEstimatedCost(res.total);
		let tokenSummary = `${res.inputTokens} in / ${res.replyTokens} out`;
		if (res.thoughtTokens > 0) {
			tokenSummary += ` + ${res.thoughtTokens >= 1000 ? (res.thoughtTokens / 1000).toFixed(1) + 'k' : res.thoughtTokens} think`;
		}
		const isSelected = (v.key === normVerbosity) || (v.key === 'detailed' && normVerbosity === 'verbose');
		const rowClass = isSelected ? ' class="selected-row"' : '';
		return `<tr${rowClass} data-verbosity="${v.key}">
			<td><strong>${v.label}</strong></td>
			<td class="cost-tokens">${tokenSummary}</td>
			<td class="cost-val"><strong>${formattedCost}</strong></td>
		</tr>`;
	}).join('');

	return `<table class="table-model-costs">
		<thead>
			<tr>
				<th>Verbosity</th>
				<th>Tokens (Est.)</th>
				<th style="text-align: right;">Est. Cost</th>
			</tr>
		</thead>
		<tbody>
			${rows}
		</tbody>
	</table>`;
}

/**
 * Updates the description, warning, and cost table DOM elements for a selected model and thinking level.
 * @param {HTMLElement|null} descEl - Description container.
 * @param {HTMLElement|null} costEl - Cost table container.
 * @param {string} familyKey - Selected model family.
 * @param {string} modelId - Selected model version.
 * @param {string|null} thinkingLevel - Selected thinking level.
 * @param {object} familiesData - Families definitions map.
 * @param {string} [selectedVerbosity='standard'] - Currently selected verbosity level.
 */
export function updateModelDetailsDisplay(descEl, costEl, familyKey, modelId, thinkingLevel, familiesData, selectedVerbosity = 'standard') {
	const familyObj = familiesData?.[familyKey];
	const modelObj = familyObj?.models?.find(m => m.model === modelId);

	if (descEl) {
		const desc = modelObj?.description || '';
		const toolsSupported = modelObj ? (modelObj.tools !== false) : true;
		let html = '';
		if (!toolsSupported) {
			html += `<div class="div-model-warning">This model does not support tool use.</div>`;
		}
		if (desc) {
			html += `<div class="div-model-desc-text">${desc}</div>`;
		}
		descEl.innerHTML = html;
		descEl.style.display = html ? 'block' : 'none';
	}

	if (costEl) {
		if (modelId) {
			costEl.innerHTML = renderModelCostTableHtml(modelId, thinkingLevel, selectedVerbosity);
			costEl.style.display = 'block';
		} else {
			costEl.innerHTML = '';
			costEl.style.display = 'none';
		}
	}
}
🌐
settings-utils.js ×
Type: Web, text/plain
22.87 Kilobytes
Last Modified 2026-09-23 10:30:38
⬇ Download File