-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 auth-storage.jsWeb, 735 B⬇
-
🌐 chat-helpers.jsWeb, 8.85 kB⬇
-
🌐 chat-renderer.jsWeb, 70.00 kB⬇
-
🌐 code-overlay.jsWeb, 21.77 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.17 kB⬇
-
🌐 conversation-format-utils.jsWeb, 21.51 kB⬇
-
🌐 diagram-overlay.jsWeb, 7.93 kB⬇
-
🌐 dialog.jsWeb, 2.50 kB⬇
-
🌐 dom-utils.jsWeb, 8.33 kB⬇
-
🌐 drag-reorder.jsWeb, 4.82 kB⬇
-
🌐 elements.jsWeb, 11.06 kB⬇
-
🌐 export-utils.jsWeb, 1.53 kB⬇
-
🌐 format-utils.jsWeb, 29.60 kB⬇
-
🌐 history-navigation.jsWeb, 1.95 kB⬇
-
🌐 hljs-yaml.jsWeb, 5.07 kB⬇
-
🌐 inline-format.jsWeb, 7.35 kB⬇
-
🌐 instant-answer.jsWeb, 19.82 kB⬇
-
🌐 interactive-tools.jsWeb, 38.77 kB⬇
-
🌐 mermaid-fixes.jsWeb, 4.60 kB⬇
-
🌐 mermaid-render.jsWeb, 5.10 kB⬇
-
🌐 mermaid.jsWeb, 195 B⬇
-
🌐 migrations.jsWeb, 12.11 kB⬇
-
🌐 multi-choice-dialog.jsWeb, 1.33 kB⬇
-
🌐 notification-stack.jsWeb, 2.50 kB⬇
-
🌐 options-manager.jsWeb, 45.56 kB⬇
-
🌐 overlay-utils.jsWeb, 4.14 kB⬇
-
🌐 pdf-export.jsWeb, 23.59 kB⬇
-
🌐 pinch-zoom.jsWeb, 3.21 kB⬇
-
🌐 plugin-dialog.jsWeb, 2.17 kB⬇
-
🌐 read-aloud.jsWeb, 33.84 kB⬇
-
🌐 response-pipeline.jsWeb, 55.19 kB⬇
-
🌐 scratchpad-editor.jsWeb, 8.64 kB⬇
-
🌐 scratchpad-export.jsWeb, 3.56 kB⬇
-
🌐 scrollbars.jsWeb, 3.12 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 31.08 kB⬇
-
🌐 share.jsWeb, 10.75 kB⬇
-
🌐 side-drawer.jsWeb, 9.37 kB⬇
-
🌐 speech-text.jsWeb, 6.60 kB⬇
-
🌐 swipe-navigation.jsWeb, 6.16 kB⬇
-
🌐 table-overlay.jsWeb, 8.10 kB⬇
-
🌐 theme-utils.jsWeb, 8.34 kB⬇
-
🌐 thinking-panel-base.jsWeb, 14.88 kB⬇
-
🌐 ui-sets.jsWeb, 1.00 kB⬇
-
🌐 version-overlay.jsWeb, 4.63 kB⬇
/**
* Utilities for populating settings form controls and synchronizing model option values.
*/
import Api from '../api.js';
/**
* Finds the rates entry for a model identifier in the loaded models.json catalog.
* @param {string} model - Model identifier.
* @returns {Object|null} Rates object with in/out/search/search_flat_rate, or null.
*/
function getCatalogRates(model) {
const id = String(model || '');
for (const family of Object.values(Api.MODELS || {})) {
const entry = (family.models || []).find(item => item.model === id);
if (entry?.rates) return entry.rates;
}
return null;
}
/**
* Finds the rates entry for a TTS model identifier in the loaded voice-models.json catalog.
* @param {string} model - TTS model identifier.
* @returns {Object|null} Rates object with in/out, or null.
*/
function getVoiceCatalogRates(model) {
const id = String(model || '');
for (const family of Object.values(Api.VOICE_MODELS || {})) {
const entry = (family.models || []).find(item => item.key === id);
if (entry?.rates) return entry.rates;
}
return null;
}
/**
* 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
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.5-fl'] - Key of the utility model to select.
*/
export function populateUtilityModelDropdown(selectEl, utilityModels, selectedKey = 'gemini-3.5-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 the voice models of all voice families.
* @param {HTMLSelectElement|null} selectEl - Target select element.
* @param {object} voiceModels - Map of voice family definitions from voice-models.json.
* @param {string|null} [selectedKey=''] - Key of the voice model to select.
*/
export function populateTtsModelDropdown(selectEl, voiceModels, selectedKey = '') {
if (!selectEl) return;
selectEl.innerHTML = '';
if (!voiceModels || typeof voiceModels !== 'object') return;
for (const familyKey in voiceModels) {
if (!Object.prototype.hasOwnProperty.call(voiceModels, familyKey)) continue;
for (const model of voiceModels[familyKey].models || []) {
const option = document.createElement('option');
option.value = model.key;
option.textContent = model.name || model.key;
if (model.key === selectedKey) {
option.selected = true;
}
selectEl.appendChild(option);
}
}
}
/**
* Populates a select dropdown with the voices of all voice families.
* @param {HTMLSelectElement|null} selectEl - Target select element.
* @param {object} voiceModels - Map of voice family definitions from voice-models.json.
* @param {string|null} [selectedKey=''] - Id of the voice to select.
*/
export function populateTtsVoiceDropdown(selectEl, voiceModels, selectedKey = '') {
if (!selectEl) return;
selectEl.innerHTML = '';
if (!voiceModels || typeof voiceModels !== 'object') return;
for (const familyKey in voiceModels) {
if (!Object.prototype.hasOwnProperty.call(voiceModels, familyKey)) continue;
for (const voice of voiceModels[familyKey].voices || []) {
const option = document.createElement('option');
option.value = voice.id;
option.textContent = voice.name || voice.id;
if (voice.id === 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.
* Catalog rates from models.json take precedence; the table below is the
* legacy fallback for models missing from the catalog.
* @param {string} model - Model identifier.
* @returns {Array<number>} [rateInput, rateOutput]
*/
export function getModelRates(model) {
if (!model) return [0.30, 2.50];
const catalog = getCatalogRates(String(model));
if (catalog && Number.isFinite(catalog.in) && Number.isFinite(catalog.out)) {
return [catalog.in, catalog.out];
}
const m = String(model).toLowerCase();
// Legacy fallback: peak and non-promotional prices, discount windows ignored
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, 2.20];
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.30, 1.20];
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.6') || m.includes('3.7') || m.includes('3.8')) && 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];
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();
const catalog = getCatalogRates(String(modelId || ''));
let rate = 0.01;
if (catalog && Number.isFinite(catalog.search)) {
// search_flat_rate true bills the fee per search event; false bills per
// executed search query/result with the recorded use count as the units
rate = catalog.search;
} else 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 };
}
/** Fallback per-million-token rates for a TTS model missing from the catalog (input text, output audio). */
const TTS_FALLBACK_RATES = { in: 1.00, out: 12.00 };
const TTS_SPEAKER_WORDS_PER_MINUTE = 100;
const TTS_INPUT_TOKENS_PER_WORD = 1.1;
const TTS_AUDIO_TOKENS_PER_SECOND = 25;
const TTS_MARKUP = 1.02;
/**
* Calculates the estimated cost per minute of readback audio for a TTS model,
* assuming a speaker at ~100 spoken words per minute (measured from readback)
* and the Gemini TTS audio token rate of 25 tokens per second. Rates come
* from voice-models.json; unknown models fall back to the flash-lite catalog
* entry, then the fallback rates above.
* @param {string} modelId - TTS model API identifier.
* @returns {{total: number, inputTokens: number, outputTokens: number}}
*/
export function calculateTtsCostPerMinute(modelId) {
const rates = getVoiceCatalogRates(modelId) || getVoiceCatalogRates('gemini-3.8-flash-lite-tts') || TTS_FALLBACK_RATES;
const inputTokens = TTS_SPEAKER_WORDS_PER_MINUTE * TTS_INPUT_TOKENS_PER_WORD;
const outputTokens = TTS_AUDIO_TOKENS_PER_SECOND * 60;
const total = (inputTokens / 1000000) * rates.in + (outputTokens / 1000000) * rates.out;
return { total, inputTokens, outputTokens };
}
/**
* Calculates the readback cost for one spoken text from its measured audio
* duration, mirroring the server-side charge (Costs::calculateTts): the
* actual played seconds drive the audio tokens, the word count estimates the
* input tokens, and the charge carries the server's 2% markup.
* @param {string} text - The plain text that was spoken.
* @param {number} duration - The measured audio duration in seconds.
* @param {string} modelId - TTS model API identifier.
* @returns {number} The cost in dollars.
*/
export function calculateTtsMeasuredCost(text, duration, modelId) {
const rates = getVoiceCatalogRates(modelId) || getVoiceCatalogRates('gemini-3.8-flash-lite-tts') || TTS_FALLBACK_RATES;
const words = String(text || '').trim().split(/\s+/).filter(Boolean).length;
const inputTokens = words * TTS_INPUT_TOKENS_PER_WORD;
const outputTokens = Math.max(0, duration) * TTS_AUDIO_TOKENS_PER_SECOND;
return ((inputTokens / 1000000) * rates.in + (outputTokens / 1000000) * rates.out) * TTS_MARKUP;
}
/**
* 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)}`;
}
/**
* Estimates the token usage of one ranking request: the fixed prompt and
* schema overhead plus about one sentence per choice, and a ranked list plus
* explanation on the output side.
* @param {string} modelId - Selected model ID.
* @param {string|null} thinkingLevel - Selected thinking level.
* @param {number} choiceCount - Number of choices being ranked.
* @returns {{total: number, inputTokens: number, replyTokens: number, thoughtTokens: number}}
*/
export function calculateRankingEstimatedCost(modelId, thinkingLevel, choiceCount) {
const [rateInput, rateOutput] = getModelRates(modelId);
const { thoughtTokens } = getEstimatedTokens('standard', thinkingLevel);
const inputTokens = 1600 + choiceCount * 30;
const replyTokens = 300 + choiceCount * 25;
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 };
}
/**
* Generates an HTML table of estimated ranking costs across common choice
* counts (each choice assumed to be about one sentence).
* @param {string} modelId - Selected model ID.
* @param {string|null} thinkingLevel - Selected thinking level.
* @returns {string} HTML table string.
*/
export function renderRankingCostTableHtml(modelId, thinkingLevel) {
const counts = [2, 5, 10, 50];
const rows = counts.map(count => {
const res = calculateRankingEstimatedCost(modelId, thinkingLevel, count);
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`;
}
return `<tr data-choices="${count}">
<td><strong>${count}</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>Choices</th>
<th>Tokens (Est.)</th>
<th style="text-align: right;">Est. Cost</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>`;
}
/**
* Formats the instant answer estimated cost, rounded to 3 decimal places with a minimum of 0.001.
* @param {number} cost - The cost in dollars.
* @returns {string} Formatted string (e.g., "$0.012").
*/
export function formatInstantAnswerCost(cost) {
const rounded = Math.max(0.001, Math.round(cost * 1000) / 1000);
return `$${rounded.toFixed(3)}`;
}
/**
* 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';
}
}
}
/**
* One-line descriptions for each verbosity setting.
*/
const VERBOSITY_DESCRIPTIONS = {
minimal: 'Short, direct answers with no extra detail.',
standard: 'Clear, informative answers with relevant detail.',
thorough: 'In-depth explanations with sections, tables, and diagrams.',
detailed: 'Comprehensive, article-style coverage with extensive detail.'
};
/**
* Shows a one-line description of the selected verbosity setting.
* @param {HTMLElement|null} el - The verbosity description container.
* @param {string} verbosity - The selected verbosity level.
*/
export function updateVerbosityDescription(el, verbosity) {
if (!el) return;
el.textContent = VERBOSITY_DESCRIPTIONS[verbosity] || VERBOSITY_DESCRIPTIONS.standard;
el.style.display = 'block';
}
settings-utils.js
×
Type: Web, text/x-java
31.08 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/x-java
31.08 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File