Group provider/rotation/autoselect config forms into collapsible sub-panels

The provider, rotation and autoselect detail forms were single huge forms.
Split them into collapsible sub-panels so only basic settings stay visible:

- Shared subPanel()/toggleSubPanel() helper + CSS in base.html, defined
  before the content block so page scripts can call it during first render.
- Providers: basic fields visible; Authentication, Pricing & Tiers, Rate
  limits & defaults, Feature Overrides, Native Caching, Models as panels;
  each model has basic config visible + an Advanced sub-panel.
- Rotations: basic visible; Advanced configuration panel; one panel per
  provider (model-count + usage badge); one panel per model with an
  Advanced sub-panel.
- Autoselect: basic visible; Classification, Feature Overrides, Default
  settings panels; one panel per available model.
- Panel open/close state and top-level item expansion persist via
  sessionStorage, so they survive navigation within a session.

Bump version to 0.99.75.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent bbe17ff0
...@@ -55,7 +55,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -55,7 +55,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.74" __version__ = "0.99.75"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
......
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aisbf" name = "aisbf"
version = "0.99.74" version = "0.99.75"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations" description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
......
...@@ -106,7 +106,7 @@ class InstallCommand(_install): ...@@ -106,7 +106,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.74", version="0.99.75",
author="AISBF Contributors", author="AISBF Contributors",
author_email="stefy@nexlab.net", author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations", description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
...@@ -898,6 +898,97 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -898,6 +898,97 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
{% endif %} {% endif %}
<!-- Shared collapsible sub-panel helper (used by providers/rotations/autoselect config forms).
Defined before the content block so page scripts can call subPanel() during initial render. -->
<style>
.sub-panel {
border: 1px solid var(--color-border);
border-radius: 4px;
margin-bottom: 12px;
background: var(--bg-page);
overflow: hidden;
}
.sub-panel-header {
padding: 10px 12px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-accent);
font-weight: 600;
font-size: 14px;
color: var(--color-text);
}
.sub-panel-header:hover { filter: brightness(1.08); }
.sub-panel-header .sp-chevron { font-size: 12px; width: 14px; display: inline-block; color: var(--color-muted); }
.sub-panel-header .sp-title { flex: 1; }
.sub-panel-header .sp-badge { font-weight: 400; font-size: 12px; color: var(--color-muted); }
.sub-panel-body { padding: 14px 12px; border-top: 1px solid var(--color-border); }
.sub-panel.sp-nested { background: var(--bg-panel); }
</style>
<script>
(function(){
// Tracks which sub-panels are open, keyed by a stable string. State
// survives re-renders of a detail form so panels don't snap shut when
// an unrelated field re-renders the list, and is persisted to
// sessionStorage so open/close state is remembered while navigating
// between dashboard pages within the same browser session.
const _SP_STORE = 'aisbf_open_subpanels';
function _spLoad() {
try { return new Set(JSON.parse(sessionStorage.getItem(_SP_STORE) || '[]')); }
catch (e) { return new Set(); }
}
function _spSave() {
try { sessionStorage.setItem(_SP_STORE, JSON.stringify([...window._openSubPanels])); }
catch (e) { /* sessionStorage unavailable — keep in-memory only */ }
}
window._openSubPanels = window._openSubPanels || _spLoad();
function _spEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// Build a collapsible sub-panel. `key` must be unique per logical panel.
// opts: {badge, nested, open} — `open:true` makes it default-expanded the
// first time it is seen (afterwards user state in _openSubPanels wins).
window.subPanel = function(key, title, innerHtml, opts) {
opts = opts || {};
if (opts.open && !window._openSubPanels.has('__seen__' + key)) {
window._openSubPanels.add('__seen__' + key);
window._openSubPanels.add(key);
_spSave();
}
const isOpen = window._openSubPanels.has(key);
const safe = _spEsc(key);
const chev = isOpen ? '▼' : '▶';
const disp = isOpen ? 'block' : 'none';
const badge = opts.badge ? `<span class="sp-badge">${opts.badge}</span>` : '';
const nestedCls = opts.nested ? ' sp-nested' : '';
return `
<div class="sub-panel${nestedCls}">
<div class="sub-panel-header" onclick="toggleSubPanel('${safe}', this)">
<span class="sp-chevron">${chev}</span>
<span class="sp-title">${title}</span>
${badge}
</div>
<div class="sub-panel-body" style="display:${disp};">${innerHtml}</div>
</div>`;
};
window.toggleSubPanel = function(key, headerEl) {
const body = headerEl.parentElement.querySelector('.sub-panel-body');
if (!body) return;
const willOpen = !window._openSubPanels.has(key);
if (willOpen) window._openSubPanels.add(key); else window._openSubPanels.delete(key);
_spSave();
body.style.display = willOpen ? 'block' : 'none';
const chev = headerEl.querySelector('.sp-chevron');
if (chev) chev.textContent = willOpen ? '▼' : '▶';
};
})();
</script>
<div class="container{% if studio_body_mode == 'wide' %} container-wide{% endif %}"> <div class="container{% if studio_body_mode == 'wide' %} container-wide{% endif %}">
<div class="content{% if studio_body_mode == 'wide' %} content-wide{% endif %}"> <div class="content{% if studio_body_mode == 'wide' %} content-wide{% endif %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
......
...@@ -56,7 +56,15 @@ let autoselectConfig = autoselectData.config; ...@@ -56,7 +56,15 @@ let autoselectConfig = autoselectData.config;
let availableRotations = autoselectData.rotations; let availableRotations = autoselectData.rotations;
let availableModels = autoselectData.models; let availableModels = autoselectData.models;
const providersMeta = {{ providers_meta | default('{}') | safe }}; const providersMeta = {{ providers_meta | default('{}') | safe }};
let expandedAutoselects = new Set(); function _loadExpanded(storeKey) {
try { return new Set(JSON.parse(sessionStorage.getItem(storeKey) || '[]')); }
catch (e) { return new Set(); }
}
let expandedAutoselects = _loadExpanded('aisbf_expanded_autoselects');
function _saveExpandedAutoselects() {
try { sessionStorage.setItem('aisbf_expanded_autoselects', JSON.stringify([...expandedAutoselects])); }
catch (e) { /* ignore */ }
}
let autoselectMasterOrder = Object.keys(autoselectConfig || {}); let autoselectMasterOrder = Object.keys(autoselectConfig || {});
let _autoselectDS = null; let _autoselectDS = null;
...@@ -349,6 +357,7 @@ function toggleAutoselect(key) { ...@@ -349,6 +357,7 @@ function toggleAutoselect(key) {
} else { } else {
expandedAutoselects.add(key); expandedAutoselects.add(key);
} }
_saveExpandedAutoselects();
renderAutoselectList(); renderAutoselectList();
} }
...@@ -393,20 +402,7 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -393,20 +402,7 @@ function renderAutoselectDetails(autoselectKey) {
).join(''); ).join('');
fallbackOptions += '</optgroup>'; fallbackOptions += '</optgroup>';
container.innerHTML = ` const classificationInner = `
<div class="form-group">
<label>${window.i18n.t('autoselect.model_name')}</label>
<input type="text" value="${autoselect.model_name}" onchange="updateAutoselect('${autoselectKey}', 'model_name', this.value)" required>
</div>
<div class="form-group">
<label>${window.i18n.t('autoselect.capabilities')}</label>
<input type="text" value="${autoselect.capabilities ? autoselect.capabilities.join(', ') : ''}" onchange="updateAutoselectCapabilities('${autoselectKey}', this.value)" placeholder="${window.i18n.t('autoselect.capabilities_placeholder')}">
<small style="color: var(--color-muted); display:block; margin-top:5px;">This field is auto-inherited from the capabilities shared by all available models when you save.</small>
<div style="margin-top:8px;">${inheritedCapsHtml}</div>
${partialCapsHtml}
</div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" ${autoselect.classify_nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_nsfw', this.checked)"> <input type="checkbox" ${autoselect.classify_nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_nsfw', this.checked)">
...@@ -414,7 +410,7 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -414,7 +410,7 @@ function renderAutoselectDetails(autoselectKey) {
</label> </label>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_nsfw_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_nsfw_desc')}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" ${autoselect.classify_privacy ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_privacy', this.checked)"> <input type="checkbox" ${autoselect.classify_privacy ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_privacy', this.checked)">
...@@ -422,7 +418,7 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -422,7 +418,7 @@ function renderAutoselectDetails(autoselectKey) {
</label> </label>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_privacy_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_privacy_desc')}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" ${autoselect.classify_semantic ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_semantic', this.checked)"> <input type="checkbox" ${autoselect.classify_semantic ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_semantic', this.checked)">
...@@ -430,8 +426,9 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -430,8 +426,9 @@ function renderAutoselectDetails(autoselectKey) {
</label> </label>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small>
</div> </div>
`;
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4> const featureOverridesInner = `
${triStateSelect('Context Condensation', autoselect.enable_context_condensation, `updateAutoselectTriState('${autoselectKey}', 'enable_context_condensation', this.value)`)} ${triStateSelect('Context Condensation', autoselect.enable_context_condensation, `updateAutoselectTriState('${autoselectKey}', 'enable_context_condensation', this.value)`)}
${triStateSelect('Response Cache', autoselect.enable_response_cache, `updateAutoselectTriState('${autoselectKey}', 'enable_response_cache', this.value)`)} ${triStateSelect('Response Cache', autoselect.enable_response_cache, `updateAutoselectTriState('${autoselectKey}', 'enable_response_cache', this.value)`)}
${triStateSelect('Prompt Batching', autoselect.enable_prompt_batching, `updateAutoselectTriState('${autoselectKey}', 'enable_prompt_batching', this.value)`)} ${triStateSelect('Prompt Batching', autoselect.enable_prompt_batching, `updateAutoselectTriState('${autoselectKey}', 'enable_prompt_batching', this.value)`)}
...@@ -440,74 +437,94 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -440,74 +437,94 @@ function renderAutoselectDetails(autoselectKey) {
${triStateSelect('Block High-Risk Prompts', autoselect.block_high_risk_prompts, `updateAutoselectTriState('${autoselectKey}', 'block_high_risk_prompts', this.value)`)} ${triStateSelect('Block High-Risk Prompts', autoselect.block_high_risk_prompts, `updateAutoselectTriState('${autoselectKey}', 'block_high_risk_prompts', this.value)`)}
${triStateSelect('NSFW Classification', autoselect.enable_nsfw_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_nsfw_classification', this.value)`)} ${triStateSelect('NSFW Classification', autoselect.enable_nsfw_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_nsfw_classification', this.value)`)}
${triStateSelect('Privacy Classification', autoselect.enable_privacy_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_privacy_classification', this.value)`)} ${triStateSelect('Privacy Classification', autoselect.enable_privacy_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_privacy_classification', this.value)`)}
<div class="form-group"> <div class="form-group" style="margin-top: 15px;">
<label>${window.i18n.t('autoselect.description')}</label> <label>${window.i18n.t('autoselect.description')}</label>
<textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea> <textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea>
</div> </div>
`;
<div class="form-group">
<label>${window.i18n.t('autoselect.selection_model')}</label> const defaultSettingsInner = `
<select onchange="updateAutoselect('${autoselectKey}', 'selection_model', this.value)" required>
<option value="">${window.i18n.t('autoselect.select_model')}</option>
${selectionOptions}
</select>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.selection_model_desc')}</small>
</div>
<div class="form-group">
<label>${window.i18n.t('autoselect.fallback_model')}</label>
<select onchange="updateAutoselect('${autoselectKey}', 'fallback', this.value)" required>
<option value="">${window.i18n.t('autoselect.select_model')}</option>
${fallbackOptions}
</select>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.fallback_model_desc')}</small>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('autoselect.default_settings')}</h4>
<p style="color: var(--color-muted); font-size: 14px; margin-bottom: 10px;">${window.i18n.t('autoselect.default_settings_desc')}</p> <p style="color: var(--color-muted); font-size: 14px; margin-bottom: 10px;">${window.i18n.t('autoselect.default_settings_desc')}</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.default_rate_limit')}</label> <label>${window.i18n.t('autoselect.default_rate_limit')}</label>
<input type="number" value="${autoselect.default_rate_limit || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit', this.value ? parseFloat(this.value) : null)" step="0.1" placeholder="${window.i18n.t('autoselect.optional')}"> <input type="number" value="${autoselect.default_rate_limit || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit', this.value ? parseFloat(this.value) : null)" step="0.1" placeholder="${window.i18n.t('autoselect.optional')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.default_max_request_tokens')}</label> <label>${window.i18n.t('autoselect.default_max_request_tokens')}</label>
<input type="number" value="${autoselect.default_max_request_tokens || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_max_request_tokens', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}"> <input type="number" value="${autoselect.default_max_request_tokens || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_max_request_tokens', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.default_context_size')}</label> <label>${window.i18n.t('autoselect.default_context_size')}</label>
<input type="number" value="${autoselect.default_context_size || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}"> <input type="number" value="${autoselect.default_context_size || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.default_rate_limit_tpm')}</label> <label>${window.i18n.t('autoselect.default_rate_limit_tpm')}</label>
<input type="number" value="${autoselect.default_rate_limit_TPM || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit_TPM', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}"> <input type="number" value="${autoselect.default_rate_limit_TPM || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit_TPM', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}">
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_rate_limit_tpm_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_rate_limit_tpm_desc')}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.default_rate_limit_tph')}</label> <label>${window.i18n.t('autoselect.default_rate_limit_tph')}</label>
<input type="number" value="${autoselect.default_rate_limit_TPH || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit_TPH', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}"> <input type="number" value="${autoselect.default_rate_limit_TPH || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit_TPH', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}">
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_rate_limit_tph_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_rate_limit_tph_desc')}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.default_rate_limit_tpd')}</label> <label>${window.i18n.t('autoselect.default_rate_limit_tpd')}</label>
<input type="number" value="${autoselect.default_rate_limit_TPD || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit_TPD', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}"> <input type="number" value="${autoselect.default_rate_limit_TPD || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_rate_limit_TPD', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}">
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_rate_limit_tpd_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_rate_limit_tpd_desc')}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.default_condense_context')}</label> <label>${window.i18n.t('autoselect.default_condense_context')}</label>
<input type="number" value="${autoselect.default_condense_context || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_condense_context', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}"> <input type="number" value="${autoselect.default_condense_context || ''}" onchange="updateAutoselect('${autoselectKey}', 'default_condense_context', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('autoselect.optional')}">
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_condense_context_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.default_condense_context_desc')}</small>
</div> </div>
</div> </div>
`;
container.innerHTML = `
<div class="form-group">
<label>${window.i18n.t('autoselect.model_name')}</label>
<input type="text" value="${autoselect.model_name}" onchange="updateAutoselect('${autoselectKey}', 'model_name', this.value)" required>
</div>
<div class="form-group">
<label>${window.i18n.t('autoselect.selection_model')}</label>
<select onchange="updateAutoselect('${autoselectKey}', 'selection_model', this.value)" required>
<option value="">${window.i18n.t('autoselect.select_model')}</option>
${selectionOptions}
</select>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.selection_model_desc')}</small>
</div>
<div class="form-group">
<label>${window.i18n.t('autoselect.fallback_model')}</label>
<select onchange="updateAutoselect('${autoselectKey}', 'fallback', this.value)" required>
<option value="">${window.i18n.t('autoselect.select_model')}</option>
${fallbackOptions}
</select>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.fallback_model_desc')}</small>
</div>
<div class="form-group">
<label>${window.i18n.t('autoselect.capabilities')}</label>
<input type="text" value="${autoselect.capabilities ? autoselect.capabilities.join(', ') : ''}" onchange="updateAutoselectCapabilities('${autoselectKey}', this.value)" placeholder="${window.i18n.t('autoselect.capabilities_placeholder')}">
<small style="color: var(--color-muted); display:block; margin-top:5px;">This field is auto-inherited from the capabilities shared by all available models when you save.</small>
<div style="margin-top:8px;">${inheritedCapsHtml}</div>
${partialCapsHtml}
</div>
${subPanel(`asel:${autoselectKey}:classification`, 'Classification', classificationInner)}
${subPanel(`asel:${autoselectKey}:features`, 'Feature Overrides', featureOverridesInner)}
${subPanel(`asel:${autoselectKey}:defaults`, window.i18n.t('autoselect.default_settings'), defaultSettingsInner)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('autoselect.available_models')}</h4> <h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('autoselect.available_models')}</h4>
<p style="color: var(--color-muted); font-size: 14px; margin-bottom: 10px;">${window.i18n.t('autoselect.available_models_desc')}</p> <p style="color: var(--color-muted); font-size: 14px; margin-bottom: 10px;">${window.i18n.t('autoselect.available_models_desc')}</p>
<div id="models-${autoselectKey}"></div> <div id="models-${autoselectKey}"></div>
...@@ -531,10 +548,8 @@ function renderAutoselectModels(autoselectKey) { ...@@ -531,10 +548,8 @@ function renderAutoselectModels(autoselectKey) {
return; return;
} }
container.innerHTML = ''; let html = '';
autoselect.available_models.forEach((model, index) => { autoselect.available_models.forEach((model, index) => {
const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid var(--color-border); padding: 15px; margin-bottom: 10px; border-radius: 3px; background: var(--bg-page);';
const safeKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const safeKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const allOptions = availableRotations.map(r => ({id: r, name: r})); const allOptions = availableRotations.map(r => ({id: r, name: r}));
const selUid = `asel-mod-${autoselectKey}-${index}`; const selUid = `asel-mod-${autoselectKey}-${index}`;
...@@ -544,12 +559,8 @@ function renderAutoselectModels(autoselectKey) { ...@@ -544,12 +559,8 @@ function renderAutoselectModels(autoselectKey) {
const _usageBadge = _modelPid && _providerSupportsUsage(_modelPid) const _usageBadge = _modelPid && _providerSupportsUsage(_modelPid)
? `<span data-usage-pid="${_modelPid}" style="margin-left:6px;">${_modelUsage}</span>` : ''; ? `<span data-usage-pid="${_modelPid}" style="margin-left:6px;">${_modelUsage}</span>` : '';
modelDiv.innerHTML = ` const body = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; flex-wrap:wrap; gap:6px;"> <div style="display: flex; justify-content: flex-end; align-items: center; margin-bottom: 10px;">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px;">
<strong>${window.i18n.t('autoselect.model_label')} ${index + 1}</strong>
${_usageBadge}
</div>
<button type="button" class="btn btn-secondary" onclick="removeAutoselectModel('${safeKey}', ${index})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">${window.i18n.t('autoselect.remove')}</button> <button type="button" class="btn btn-secondary" onclick="removeAutoselectModel('${safeKey}', ${index})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">${window.i18n.t('autoselect.remove')}</button>
</div> </div>
...@@ -559,17 +570,18 @@ function renderAutoselectModels(autoselectKey) { ...@@ -559,17 +570,18 @@ function renderAutoselectModels(autoselectKey) {
`updateAutoselectModel('${safeKey}',${index},'model_id',this.value)`)} `updateAutoselectModel('${safeKey}',${index},'model_id',this.value)`)}
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.model_id_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.model_id_desc')}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.model_description')}</label> <label>${window.i18n.t('autoselect.model_description')}</label>
<textarea onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'description', this.value)" style="min-height: 80px;" required>${model.description || ''}</textarea> <textarea onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'description', this.value)" style="min-height: 80px;" required>${model.description || ''}</textarea>
<small style="color: var(--color-subtle); font-size: 12px;">${window.i18n.t('autoselect.model_description_hint')}</small> <small style="color: var(--color-subtle); font-size: 12px;">${window.i18n.t('autoselect.model_description_hint')}</small>
</div> </div>
`; `;
container.appendChild(modelDiv); const mTitle = `${window.i18n.t('autoselect.model_label')} ${index + 1}${model.model_id ? ' — ' + escHtmlAttr(model.model_id) : ''}`;
html += subPanel(`asel:${autoselectKey}:mdl:${index}`, mTitle, body, {badge: _usageBadge});
}); });
container.innerHTML = html;
} }
async function apiCall(method, url, body) { async function apiCall(method, url, body) {
......
...@@ -435,7 +435,15 @@ let providersData = {}; ...@@ -435,7 +435,15 @@ let providersData = {};
const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices | tojson }}; const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices | tojson }};
const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_choices | tojson }}; const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_choices | tojson }};
const STUDIO_ADAPTER_PROFILE_CHOICES = {{ studio_adapter_profile_choices | tojson }}; const STUDIO_ADAPTER_PROFILE_CHOICES = {{ studio_adapter_profile_choices | tojson }};
let expandedProviders = new Set(); function _loadExpanded(storeKey) {
try { return new Set(JSON.parse(sessionStorage.getItem(storeKey) || '[]')); }
catch (e) { return new Set(); }
}
let expandedProviders = _loadExpanded('aisbf_expanded_providers');
function _saveExpandedProviders() {
try { sessionStorage.setItem('aisbf_expanded_providers', JSON.stringify([...expandedProviders])); }
catch (e) { /* ignore */ }
}
let currentProviderPage = 0; let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10; const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = ''; let providerSearchFilter = '';
...@@ -938,6 +946,7 @@ function toggleProvider(key) { ...@@ -938,6 +946,7 @@ function toggleProvider(key) {
const idx = filteredKeys.indexOf(key); const idx = filteredKeys.indexOf(key);
if (idx >= 0) currentProviderPage = Math.floor(idx / PROVIDERS_PAGE_SIZE); if (idx >= 0) currentProviderPage = Math.floor(idx / PROVIDERS_PAGE_SIZE);
} }
_saveExpandedProviders();
renderProvidersList(); renderProvidersList();
} }
...@@ -1779,11 +1788,11 @@ function renderProviderDetails(key) { ...@@ -1779,11 +1788,11 @@ function renderProviderDetails(key) {
</select> </select>
</div> </div>
${authFieldsHtml} ${subPanel(`providers:${key}:auth`, window.i18n.t('providers.authentication') || 'Authentication', authFieldsHtml, {open: true})}
<div style="background: var(--bg-panel); padding: 15px; border-radius: 5px; margin: 15px 0; border-left: 3px solid #4a9eff;"> ${subPanel(`providers:${key}:pricing`, window.i18n.t('providers.pricing_section'), `
<h4 style="margin: 0 0 15px 0; color: var(--color-link);">${window.i18n.t('providers.pricing_section')}</h4> <div style="border-left: 3px solid #4a9eff; padding-left: 12px;">
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" ${provider.is_subscription ? 'checked' : ''} onchange="updateProvider('${key}', 'is_subscription', this.checked); togglePricingFields('${key}')"> <input type="checkbox" ${provider.is_subscription ? 'checked' : ''} onchange="updateProvider('${key}', 'is_subscription', this.checked); togglePricingFields('${key}')">
...@@ -1874,7 +1883,9 @@ function renderProviderDetails(key) { ...@@ -1874,7 +1883,9 @@ function renderProviderDetails(key) {
</div> </div>
</div> </div>
</div> </div>
`)}
${subPanel(`providers:${key}:limits`, window.i18n.t('providers.rate_limits_defaults') || 'Rate limits & defaults', `
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.rate_limit')}</label> <label>${window.i18n.t('providers.rate_limit')}</label>
<input type="number" value="${provider.rate_limit || 0}" onchange="updateProvider('${key}', 'rate_limit', parseFloat(this.value))" step="0.1"> <input type="number" value="${provider.rate_limit || 0}" onchange="updateProvider('${key}', 'rate_limit', parseFloat(this.value))" step="0.1">
...@@ -1918,8 +1929,9 @@ function renderProviderDetails(key) { ...@@ -1918,8 +1929,9 @@ function renderProviderDetails(key) {
<label>${window.i18n.t('providers.default_condense_method')}</label> <label>${window.i18n.t('providers.default_condense_method')}</label>
<input type="text" value="${Array.isArray(provider.default_condense_method) ? provider.default_condense_method.join(', ') : (provider.default_condense_method || '')}" onchange="updateProviderCondenseMethod('${key}', this.value)" placeholder="e.g., semantic, conversational"> <input type="text" value="${Array.isArray(provider.default_condense_method) ? provider.default_condense_method.join(', ') : (provider.default_condense_method || '')}" onchange="updateProviderCondenseMethod('${key}', this.value)" placeholder="e.g., semantic, conversational">
</div> </div>
`)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
${subPanel(`providers:${key}:features`, 'Feature Overrides', `
<small style="color: var(--color-muted); display: block; margin-bottom: 15px;">Per-provider tri-state overrides. Inherit follows the global default.</small> <small style="color: var(--color-muted); display: block; margin-bottom: 15px;">Per-provider tri-state overrides. Inherit follows the global default.</small>
${triStateSelect('Context Condensation', provider.enable_context_condensation, `updateProviderTriState('${key}', 'enable_context_condensation', this.value)`, 'Controls whether condensation is active for this provider.')} ${triStateSelect('Context Condensation', provider.enable_context_condensation, `updateProviderTriState('${key}', 'enable_context_condensation', this.value)`, 'Controls whether condensation is active for this provider.')}
${triStateSelect('Response Cache', provider.enable_response_cache, `updateProviderTriState('${key}', 'enable_response_cache', this.value)`, 'Overrides the global response-cache default for this provider.')} ${triStateSelect('Response Cache', provider.enable_response_cache, `updateProviderTriState('${key}', 'enable_response_cache', this.value)`, 'Overrides the global response-cache default for this provider.')}
...@@ -1929,8 +1941,9 @@ function renderProviderDetails(key) { ...@@ -1929,8 +1941,9 @@ function renderProviderDetails(key) {
${triStateSelect('Block High-Risk Prompts', provider.block_high_risk_prompts, `updateProviderTriState('${key}', 'block_high_risk_prompts', this.value)`, 'Rejects high-risk prompts locally before sending upstream.')} ${triStateSelect('Block High-Risk Prompts', provider.block_high_risk_prompts, `updateProviderTriState('${key}', 'block_high_risk_prompts', this.value)`, 'Rejects high-risk prompts locally before sending upstream.')}
${triStateSelect('NSFW Classification', provider.enable_nsfw_classification, `updateProviderTriState('${key}', 'enable_nsfw_classification', this.value)`, 'Controls NSFW classification checks for this provider.')} ${triStateSelect('NSFW Classification', provider.enable_nsfw_classification, `updateProviderTriState('${key}', 'enable_nsfw_classification', this.value)`, 'Controls NSFW classification checks for this provider.')}
${triStateSelect('Privacy Classification', provider.enable_privacy_classification, `updateProviderTriState('${key}', 'enable_privacy_classification', this.value)`, 'Controls privacy classification checks for this provider.')} ${triStateSelect('Privacy Classification', provider.enable_privacy_classification, `updateProviderTriState('${key}', 'enable_privacy_classification', this.value)`, 'Controls privacy classification checks for this provider.')}
`)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('providers.native_caching_section')}</h4> ${subPanel(`providers:${key}:caching`, window.i18n.t('providers.native_caching_section'), `
<small style="color: var(--color-muted); display: block; margin-bottom: 15px;"> <small style="color: var(--color-muted); display: block; margin-bottom: 15px;">
Provider-native caching features (Anthropic cache_control, Google Context Caching, OpenAI and Kilo-compatible APIs) for cost reduction. Provider-native caching features (Anthropic cache_control, Google Context Caching, OpenAI and Kilo-compatible APIs) for cost reduction.
</small> </small>
...@@ -1960,8 +1973,9 @@ function renderProviderDetails(key) { ...@@ -1960,8 +1973,9 @@ function renderProviderDetails(key) {
<input type="text" value="${provider.prompt_cache_key || ''}" onchange="updateProvider('${key}', 'prompt_cache_key', this.value)" placeholder="Optional cache key for load balancer optimization"> <input type="text" value="${provider.prompt_cache_key || ''}" onchange="updateProvider('${key}', 'prompt_cache_key', this.value)" placeholder="Optional cache key for load balancer optimization">
<small style="color: var(--color-muted); display: block; margin-top: 5px;">Optional cache key for OpenAI/Kilo load balancer routing optimization</small> <small style="color: var(--color-muted); display: block; margin-top: 5px;">Optional cache key for OpenAI/Kilo load balancer routing optimization</small>
</div> </div>
`)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('providers.models_section')}</h4> ${subPanel(`providers:${key}:models`, window.i18n.t('providers.models_section'), `
<small style="color: var(--color-muted); display: block; margin-bottom: 15px;"> <small style="color: var(--color-muted); display: block; margin-bottom: 15px;">
Configure specific models for this provider, or leave empty to automatically fetch all available models from the provider's API. Configure specific models for this provider, or leave empty to automatically fetch all available models from the provider's API.
</small> </small>
...@@ -1983,6 +1997,7 @@ function renderProviderDetails(key) { ...@@ -1983,6 +1997,7 @@ function renderProviderDetails(key) {
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')">${window.i18n.t('providers.add_model')}</button> <button type="button" class="btn btn-secondary" onclick="addModel('${key}')">${window.i18n.t('providers.add_model')}</button>
</div> </div>
<div id="get-models-status-${key}" style="margin-top: 10px;"></div> <div id="get-models-status-${key}" style="margin-top: 10px;"></div>
`, {open: true})}
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--color-border); display: flex; gap: 10px; align-items: center; flex-wrap: wrap;"> <div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--color-border); display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<button type="button" class="btn" onclick="saveProvider('${safeKey}')" style="background: #22c55e;">${window.i18n.t('providers.save_this')}</button> <button type="button" class="btn" onclick="saveProvider('${safeKey}')" style="background: #22c55e;">${window.i18n.t('providers.save_this')}</button>
...@@ -2021,7 +2036,7 @@ function renderModels(providerKey) { ...@@ -2021,7 +2036,7 @@ function renderModels(providerKey) {
return; return;
} }
container.innerHTML = ''; let _modelsHtml = '';
provider.models.forEach((model, index) => { provider.models.forEach((model, index) => {
const selectedStudioCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : []; const selectedStudioCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
const inferredCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : []; const inferredCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
...@@ -2039,60 +2054,42 @@ function renderModels(providerKey) { ...@@ -2039,60 +2054,42 @@ function renderModels(providerKey) {
const capabilityNotes = Array.isArray(model.studio_capability_notes) && model.studio_capability_notes.length const capabilityNotes = Array.isArray(model.studio_capability_notes) && model.studio_capability_notes.length
? `<div style="margin-top:6px;color:var(--color-muted);font-size:12px;">${model.studio_capability_notes.map(note => escHtmlAttr(note)).join(' | ')}</div>` ? `<div style="margin-top:6px;color:var(--color-muted);font-size:12px;">${model.studio_capability_notes.map(note => escHtmlAttr(note)).join(' | ')}</div>`
: ''; : '';
const modelDiv = document.createElement('div'); const advancedInner = `
modelDiv.style.cssText = 'border: 1px solid var(--color-border); padding: 15px; margin-bottom: 10px; border-radius: 3px; background: var(--bg-page);';
modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong>${window.i18n.t('providers.model_label')} ${index + 1}</strong>
<button type="button" class="btn btn-secondary" onclick="removeModel('${providerKey}', ${index})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">${window.i18n.t('providers.remove')}</button>
</div>
<div class="form-group">
<label>${window.i18n.t('providers.model_name')}</label>
<input type="text" value="${model.name}" onchange="updateModel('${providerKey}', ${index}, 'name', this.value)" required>
</div>
<div class="form-group">
<label>${window.i18n.t('providers.rate_limit')}</label>
<input type="number" value="${model.rate_limit || 0}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit', parseFloat(this.value))" step="0.1">
</div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.max_tokens')}</label> <label>${window.i18n.t('providers.max_tokens')}</label>
<input type="number" value="${model.max_request_tokens || ''}" onchange="updateModel('${providerKey}', ${index}, 'max_request_tokens', this.value ? parseInt(this.value) : null)" placeholder="Optional"> <input type="number" value="${model.max_request_tokens || ''}" onchange="updateModel('${providerKey}', ${index}, 'max_request_tokens', this.value ? parseInt(this.value) : null)" placeholder="Optional">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.context_size')}</label> <label>${window.i18n.t('providers.context_size')}</label>
<input type="number" value="${model.context_size || ''}" onchange="updateModel('${providerKey}', ${index}, 'context_size', this.value ? parseInt(this.value) : null)" placeholder="Optional"> <input type="number" value="${model.context_size || ''}" onchange="updateModel('${providerKey}', ${index}, 'context_size', this.value ? parseInt(this.value) : null)" placeholder="Optional">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.model_rate_limit_tpm')}</label> <label>${window.i18n.t('providers.model_rate_limit_tpm')}</label>
<input type="number" value="${model.rate_limit_TPM || ''}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit_TPM', this.value ? parseInt(this.value) : null)" placeholder="Optional"> <input type="number" value="${model.rate_limit_TPM || ''}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit_TPM', this.value ? parseInt(this.value) : null)" placeholder="Optional">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.model_rate_limit_tph')}</label> <label>${window.i18n.t('providers.model_rate_limit_tph')}</label>
<input type="number" value="${model.rate_limit_TPH || ''}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit_TPH', this.value ? parseInt(this.value) : null)" placeholder="Optional"> <input type="number" value="${model.rate_limit_TPH || ''}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit_TPH', this.value ? parseInt(this.value) : null)" placeholder="Optional">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.model_rate_limit_tpd')}</label> <label>${window.i18n.t('providers.model_rate_limit_tpd')}</label>
<input type="number" value="${model.rate_limit_TPD || ''}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit_TPD', this.value ? parseInt(this.value) : null)" placeholder="Optional"> <input type="number" value="${model.rate_limit_TPD || ''}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit_TPD', this.value ? parseInt(this.value) : null)" placeholder="Optional">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.model_condense_context')}</label> <label>${window.i18n.t('providers.model_condense_context')}</label>
<input type="number" value="${model.condense_context || ''}" onchange="updateModel('${providerKey}', ${index}, 'condense_context', this.value ? parseInt(this.value) : null)" placeholder="Optional (default: 80)"> <input type="number" value="${model.condense_context || ''}" onchange="updateModel('${providerKey}', ${index}, 'condense_context', this.value ? parseInt(this.value) : null)" placeholder="Optional (default: 80)">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('providers.model_condense_method')}</label> <label>${window.i18n.t('providers.model_condense_method')}</label>
<input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateModelCondenseMethod('${providerKey}', ${index}, this.value)" placeholder="e.g., semantic, conversational"> <input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateModelCondenseMethod('${providerKey}', ${index}, this.value)" placeholder="e.g., semantic, conversational">
</div> </div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border);"> <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border);">
<h5 style="margin: 0 0 10px 0;">Feature Overrides</h5> <h5 style="margin: 0 0 10px 0;">Feature Overrides</h5>
${triStateSelect('Context Condensation', model.enable_context_condensation, `updateModelTriState('${providerKey}', ${index}, 'enable_context_condensation', this.value)`)} ${triStateSelect('Context Condensation', model.enable_context_condensation, `updateModelTriState('${providerKey}', ${index}, 'enable_context_condensation', this.value)`)}
...@@ -2134,9 +2131,29 @@ function renderModels(providerKey) { ...@@ -2134,9 +2131,29 @@ function renderModels(providerKey) {
<div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Effective profile: ${escHtmlAttr(effectiveAdapterProfile)}</div> <div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Effective profile: ${escHtmlAttr(effectiveAdapterProfile)}</div>
</div> </div>
`; `;
container.appendChild(modelDiv); const modelBody = `
<div style="display: flex; justify-content: flex-end; align-items: center; margin-bottom: 10px;">
<button type="button" class="btn btn-secondary" onclick="removeModel('${providerKey}', ${index})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">${window.i18n.t('providers.remove')}</button>
</div>
<div class="form-group">
<label>${window.i18n.t('providers.model_name')}</label>
<input type="text" value="${model.name}" onchange="updateModel('${providerKey}', ${index}, 'name', this.value)" required>
</div>
<div class="form-group">
<label>${window.i18n.t('providers.rate_limit')}</label>
<input type="number" value="${model.rate_limit || 0}" onchange="updateModel('${providerKey}', ${index}, 'rate_limit', parseFloat(this.value))" step="0.1">
</div>
${subPanel(`providers:${providerKey}:mdl:${index}:adv`, window.i18n.t('providers.advanced') || 'Advanced', advancedInner, {nested: true})}
`;
const mTitle = `${window.i18n.t('providers.model_label')} ${index + 1}${model.name ? ' — ' + escHtmlAttr(model.name) : ''}`;
_modelsHtml += subPanel(`providers:${providerKey}:mdl:${index}`, mTitle, modelBody, {nested: true});
}); });
container.innerHTML = _modelsHtml;
} }
function showAddProviderForm() { function showAddProviderForm() {
......
...@@ -64,7 +64,15 @@ let availableProviders = rotationsData.providers; ...@@ -64,7 +64,15 @@ let availableProviders = rotationsData.providers;
// instead of a real provider. The model "name" then holds the target id. // instead of a real provider. The model "name" then holds the target id.
const META_PROVIDERS = ['rotations', 'autoselect']; const META_PROVIDERS = ['rotations', 'autoselect'];
const providersMeta = {{ providers_meta | default('{}') | safe }}; const providersMeta = {{ providers_meta | default('{}') | safe }};
let expandedRotations = new Set(); function _loadExpanded(storeKey) {
try { return new Set(JSON.parse(sessionStorage.getItem(storeKey) || '[]')); }
catch (e) { return new Set(); }
}
let expandedRotations = _loadExpanded('aisbf_expanded_rotations');
function _saveExpandedRotations() {
try { sessionStorage.setItem('aisbf_expanded_rotations', JSON.stringify([...expandedRotations])); }
catch (e) { /* ignore */ }
}
let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {}); let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {});
let _rotationDS = null; let _rotationDS = null;
...@@ -350,6 +358,7 @@ function toggleRotation(key) { ...@@ -350,6 +358,7 @@ function toggleRotation(key) {
} else { } else {
expandedRotations.add(key); expandedRotations.add(key);
} }
_saveExpandedRotations();
renderRotationsList(); renderRotationsList();
} }
...@@ -366,19 +375,41 @@ function renderRotationDetails(rotationKey) { ...@@ -366,19 +375,41 @@ function renderRotationDetails(rotationKey) {
? `<div style="margin-top:8px;font-size:12px;color:var(--color-muted);">Partial across only some models: ${partialCaps.map(cap => escHtmlAttr(cap)).join(', ')}</div>` ? `<div style="margin-top:8px;font-size:12px;color:var(--color-muted);">Partial across only some models: ${partialCaps.map(cap => escHtmlAttr(cap)).join(', ')}</div>`
: ''; : '';
const advancedInner = `
<div class="form-group">
<label>${window.i18n.t('rotations.default_rate_limit')}</label>
<input type="number" value="${rotation.default_rate_limit || ''}" onchange="updateRotation('${rotationKey}', 'default_rate_limit', this.value ? parseFloat(this.value) : null)" step="0.1" placeholder="${window.i18n.t('rotations.optional')}">
</div>
<div class="form-group">
<label>${window.i18n.t('rotations.default_context_size')}</label>
<input type="number" value="${rotation.default_context_size || ''}" onchange="updateRotation('${rotationKey}', 'default_context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}">
</div>
<h4 style="margin-top: 14px; margin-bottom: 10px;">Feature Overrides</h4>
${triStateCompact('Context Condensation', rotation.enable_context_condensation, `updateRotationTriState('${rotationKey}', 'enable_context_condensation', this.value)`)}
${triStateCompact('Response Cache', rotation.enable_response_cache, `updateRotationTriState('${rotationKey}', 'enable_response_cache', this.value)`)}
${triStateCompact('Prompt Batching', rotation.enable_prompt_batching, `updateRotationTriState('${rotationKey}', 'enable_prompt_batching', this.value)`)}
${triStateCompact('Prompt Security', rotation.enable_prompt_security, `updateRotationTriState('${rotationKey}', 'enable_prompt_security', this.value)`)}
${triStateCompact('Context Lens Analytics', rotation.enable_context_lens, `updateRotationTriState('${rotationKey}', 'enable_context_lens', this.value)`)}
${triStateCompact('Block High-Risk Prompts', rotation.block_high_risk_prompts, `updateRotationTriState('${rotationKey}', 'block_high_risk_prompts', this.value)`)}
${triStateCompact('NSFW Classification', rotation.enable_nsfw_classification, `updateRotationTriState('${rotationKey}', 'enable_nsfw_classification', this.value)`)}
${triStateCompact('Privacy Classification', rotation.enable_privacy_classification, `updateRotationTriState('${rotationKey}', 'enable_privacy_classification', this.value)`)}
`;
container.innerHTML = ` container.innerHTML = `
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('rotations.model_name')}</label> <label>${window.i18n.t('rotations.model_name')}</label>
<input type="text" value="${rotation.model_name}" onchange="updateRotation('${rotationKey}', 'model_name', this.value)" required> <input type="text" value="${rotation.model_name}" onchange="updateRotation('${rotationKey}', 'model_name', this.value)" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" ${rotation.notifyerrors ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'notifyerrors', this.checked)"> <input type="checkbox" ${rotation.notifyerrors ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'notifyerrors', this.checked)">
${window.i18n.t('rotations.notify_errors')}${window.i18n.t('rotations.notify_errors_desc')} ${window.i18n.t('rotations.notify_errors')}${window.i18n.t('rotations.notify_errors_desc')}
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('rotations.capabilities')}</label> <label>${window.i18n.t('rotations.capabilities')}</label>
<input type="text" value="${rotation.capabilities ? rotation.capabilities.join(', ') : ''}" onchange="updateRotationCapabilities('${rotationKey}', this.value)" placeholder="${window.i18n.t('rotations.capabilities_placeholder')}"> <input type="text" value="${rotation.capabilities ? rotation.capabilities.join(', ') : ''}" onchange="updateRotationCapabilities('${rotationKey}', this.value)" placeholder="${window.i18n.t('rotations.capabilities_placeholder')}">
...@@ -386,27 +417,9 @@ function renderRotationDetails(rotationKey) { ...@@ -386,27 +417,9 @@ function renderRotationDetails(rotationKey) {
<div style="margin-top:8px;">${inheritedCapsHtml}</div> <div style="margin-top:8px;">${inheritedCapsHtml}</div>
${partialCapsHtml} ${partialCapsHtml}
</div> </div>
<div class="form-group"> ${subPanel(`rot:${rotationKey}:advanced`, 'Advanced configuration', advancedInner)}
<label>${window.i18n.t('rotations.default_rate_limit')}</label>
<input type="number" value="${rotation.default_rate_limit || ''}" onchange="updateRotation('${rotationKey}', 'default_rate_limit', this.value ? parseFloat(this.value) : null)" step="0.1" placeholder="${window.i18n.t('rotations.optional')}">
</div>
<div class="form-group">
<label>${window.i18n.t('rotations.default_context_size')}</label>
<input type="number" value="${rotation.default_context_size || ''}" onchange="updateRotation('${rotationKey}', 'default_context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}">
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
${triStateCompact('Context Condensation', rotation.enable_context_condensation, `updateRotationTriState('${rotationKey}', 'enable_context_condensation', this.value)`)}
${triStateCompact('Response Cache', rotation.enable_response_cache, `updateRotationTriState('${rotationKey}', 'enable_response_cache', this.value)`)}
${triStateCompact('Prompt Batching', rotation.enable_prompt_batching, `updateRotationTriState('${rotationKey}', 'enable_prompt_batching', this.value)`)}
${triStateCompact('Prompt Security', rotation.enable_prompt_security, `updateRotationTriState('${rotationKey}', 'enable_prompt_security', this.value)`)}
${triStateCompact('Context Lens Analytics', rotation.enable_context_lens, `updateRotationTriState('${rotationKey}', 'enable_context_lens', this.value)`)}
${triStateCompact('Block High-Risk Prompts', rotation.block_high_risk_prompts, `updateRotationTriState('${rotationKey}', 'block_high_risk_prompts', this.value)`)}
${triStateCompact('NSFW Classification', rotation.enable_nsfw_classification, `updateRotationTriState('${rotationKey}', 'enable_nsfw_classification', this.value)`)}
${triStateCompact('Privacy Classification', rotation.enable_privacy_classification, `updateRotationTriState('${rotationKey}', 'enable_privacy_classification', this.value)`)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('rotations.providers_section')}</h4> <h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('rotations.providers_section')}</h4>
<div id="providers-${rotationKey}"></div> <div id="providers-${rotationKey}"></div>
<button type="button" class="btn btn-secondary" onclick="addRotationProvider('${safeRKey}')" style="margin-top: 10px;">${window.i18n.t('rotations.add_provider')}</button> <button type="button" class="btn btn-secondary" onclick="addRotationProvider('${safeRKey}')" style="margin-top: 10px;">${window.i18n.t('rotations.add_provider')}</button>
...@@ -429,28 +442,21 @@ function renderRotationProviders(rotationKey) { ...@@ -429,28 +442,21 @@ function renderRotationProviders(rotationKey) {
return; return;
} }
container.innerHTML = ''; const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
let html = '';
rotation.providers.forEach((provider, providerIndex) => { rotation.providers.forEach((provider, providerIndex) => {
const providerDiv = document.createElement('div');
providerDiv.style.cssText = 'border: 1px solid var(--color-border); padding: 15px; margin-bottom: 10px; border-radius: 3px; background: var(--bg-page);';
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const provSelUid = `prov-sel-${rotationKey}-${providerIndex}`; const provSelUid = `prov-sel-${rotationKey}-${providerIndex}`;
const _pid = provider.provider_id || ''; const _pid = provider.provider_id || '';
const _usageBadge = _providerSupportsUsage(_pid) const _usageBadge = _providerSupportsUsage(_pid)
? `<span data-usage-pid="${escHtmlAttr(_pid)}" style="margin-left:6px;">${_usageCache[_pid] && _usageCache[_pid].data ? _renderUsageCompact(_usageCache[_pid].data) : ''}</span>` ? `<span data-usage-pid="${escHtmlAttr(_pid)}" style="margin-left:6px;">${_usageCache[_pid] && _usageCache[_pid].data ? _renderUsageCompact(_usageCache[_pid].data) : ''}</span>`
: ''; : '';
const modelCount = provider.models ? provider.models.length : 0;
providerDiv.innerHTML = ` const body = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; flex-wrap: wrap; gap: 6px;"> <div style="display:flex; justify-content:flex-end; align-items:center; gap:6px; margin-bottom:10px;">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px;"> ${(_pid && !META_PROVIDERS.includes(_pid)) ? `<button type="button" class="btn btn-secondary provider-toggle-btn" data-provider="${escHtmlAttr(_pid)}" onclick="toggleProviderDisabled(this)" style="padding:5px 10px;font-size:12px;">${window.i18n.t('rotations.disable') || 'Disable'}</button>` : ''}
<strong>${window.i18n.t('rotations.provider_label')} ${providerIndex + 1}</strong> <button type="button" class="btn btn-secondary" onclick="removeRotationProvider('${safeKey}', ${providerIndex})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">${window.i18n.t('rotations.remove')}</button>
${_usageBadge}
</div>
<div style="display:flex;align-items:center;gap:6px;">
${(_pid && !META_PROVIDERS.includes(_pid)) ? `<button type="button" class="btn btn-secondary provider-toggle-btn" data-provider="${escHtmlAttr(_pid)}" onclick="toggleProviderDisabled(this)" style="padding:5px 10px;font-size:12px;">${window.i18n.t('rotations.disable') || 'Disable'}</button>` : ''}
<button type="button" class="btn btn-secondary" onclick="removeRotationProvider('${safeKey}', ${providerIndex})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">${window.i18n.t('rotations.remove')}</button>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
...@@ -470,9 +476,12 @@ function renderRotationProviders(rotationKey) { ...@@ -470,9 +476,12 @@ function renderRotationProviders(rotationKey) {
<p style="font-size: 12px; color: var(--color-muted); margin-top: 5px;">${window.i18n.t('rotations.leave_empty')}</p> <p style="font-size: 12px; color: var(--color-muted); margin-top: 5px;">${window.i18n.t('rotations.leave_empty')}</p>
`; `;
container.appendChild(providerDiv); const titleLabel = `${window.i18n.t('rotations.provider_label')} ${providerIndex + 1}${_pid ? ' — ' + escHtmlAttr(_pid) : ''}`;
renderRotationModels(rotationKey, providerIndex); const badge = `${modelCount} ${modelCount !== 1 ? 'models' : 'model'}${_usageBadge}`;
html += subPanel(`rot:${rotationKey}:prov:${providerIndex}`, titleLabel, body, {badge});
}); });
container.innerHTML = html;
rotation.providers.forEach((provider, providerIndex) => renderRotationModels(rotationKey, providerIndex));
applyProviderToggleStates(); applyProviderToggleStates();
} }
...@@ -544,10 +553,8 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -544,10 +553,8 @@ function renderRotationModels(rotationKey, providerIndex) {
return; return;
} }
container.innerHTML = ''; let html = '';
provider.models.forEach((model, modelIndex) => { provider.models.forEach((model, modelIndex) => {
const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid var(--color-border); padding: 10px; margin-bottom: 8px; border-radius: 3px; background: var(--bg-panel);';
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`; const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`;
const providerId = provider.provider_id || ''; const providerId = provider.provider_id || '';
...@@ -570,51 +577,23 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -570,51 +577,23 @@ function renderRotationModels(rotationKey, providerIndex) {
onclick="msOpenProviderForInput(this)" onclick="msOpenProviderForInput(this)"
style="padding:4px 8px;font-size:11px;white-space:nowrap;">${window.i18n.t('rotations.search')}</button>`; style="padding:4px 8px;font-size:11px;white-space:nowrap;">${window.i18n.t('rotations.search')}</button>`;
modelDiv.innerHTML = ` const advancedInner = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="font-size: 13px;">${window.i18n.t('rotations.model_label')} ${modelIndex + 1}</strong>
<button type="button" onclick="removeRotationModel('${safeKey}', ${providerIndex}, ${modelIndex})" style="background: #dc3545; color: white; border: none; padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px;">${window.i18n.t('rotations.remove')}</button>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${nameLabel}</label>
<div style="display:flex;gap:5px;align-items:center;">
<input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}"
${isMeta ? `list="${escHtmlAttr(metaDlId)}"` : ''}
oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
placeholder="${escHtmlAttr(namePlaceholder)}"
required style="flex:1;font-size:12px;padding:5px;">
${nameTrailing}
</div>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.weight')}</label>
<input type="number" value="${model.weight || 1}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'weight', parseInt(this.value))" style="font-size: 12px; padding: 5px;">
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.rate_limit')}</label>
<input type="number" value="${model.rate_limit || 0}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'rate_limit', parseFloat(this.value))" step="0.1" style="font-size: 12px; padding: 5px;">
</div>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.max_request_tokens')}</label> <label style="font-size: 12px;">${window.i18n.t('rotations.max_request_tokens')}</label>
<input type="number" value="${model.max_request_tokens || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'max_request_tokens', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}" style="font-size: 12px; padding: 5px;"> <input type="number" value="${model.max_request_tokens || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'max_request_tokens', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}" style="font-size: 12px; padding: 5px;">
</div> </div>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.context_size')}</label> <label style="font-size: 12px;">${window.i18n.t('rotations.context_size')}</label>
<input type="number" value="${model.context_size || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}" style="font-size: 12px; padding: 5px;"> <input type="number" value="${model.context_size || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}" style="font-size: 12px; padding: 5px;">
</div> </div>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.condense_context_pct')}</label> <label style="font-size: 12px;">${window.i18n.t('rotations.condense_context_pct')}</label>
<input type="number" value="${model.condense_context || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'condense_context', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')} (default: 80)" style="font-size: 12px; padding: 5px;"> <input type="number" value="${model.condense_context || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'condense_context', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')} (default: 80)" style="font-size: 12px; padding: 5px;">
</div> </div>
<div class="form-group" style="margin-bottom: 0;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.condense_method')}</label> <label style="font-size: 12px;">${window.i18n.t('rotations.condense_method')}</label>
<input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateRotationModelCondenseMethod('${rotationKey}', ${providerIndex}, ${modelIndex}, this.value)" placeholder="${window.i18n.t('rotations.condense_method_placeholder')}" style="font-size: 12px; padding: 5px;"> <input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateRotationModelCondenseMethod('${rotationKey}', ${providerIndex}, ${modelIndex}, this.value)" placeholder="${window.i18n.t('rotations.condense_method_placeholder')}" style="font-size: 12px; padding: 5px;">
</div> </div>
...@@ -630,9 +609,42 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -630,9 +609,42 @@ function renderRotationModels(rotationKey, providerIndex) {
${triStateCompact('Privacy Classification', model.enable_privacy_classification, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_privacy_classification', this.value)`)} ${triStateCompact('Privacy Classification', model.enable_privacy_classification, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_privacy_classification', this.value)`)}
</div> </div>
`; `;
container.appendChild(modelDiv); const modelBody = `
<div style="display: flex; justify-content: flex-end; align-items: center; margin-bottom: 8px;">
<button type="button" onclick="removeRotationModel('${safeKey}', ${providerIndex}, ${modelIndex})" style="background: #dc3545; color: white; border: none; padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px;">${window.i18n.t('rotations.remove')}</button>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${nameLabel}</label>
<div style="display:flex;gap:5px;align-items:center;">
<input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}"
${isMeta ? `list="${escHtmlAttr(metaDlId)}"` : ''}
oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
placeholder="${escHtmlAttr(namePlaceholder)}"
required style="flex:1;font-size:12px;padding:5px;">
${nameTrailing}
</div>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.weight')}</label>
<input type="number" value="${model.weight || 1}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'weight', parseInt(this.value))" style="font-size: 12px; padding: 5px;">
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.rate_limit')}</label>
<input type="number" value="${model.rate_limit || 0}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'rate_limit', parseFloat(this.value))" step="0.1" style="font-size: 12px; padding: 5px;">
</div>
${subPanel(`rot:${rotationKey}:prov:${providerIndex}:mdl:${modelIndex}:adv`, window.i18n.t('rotations.advanced') || 'Advanced', advancedInner, {nested: true})}
`;
const mTitle = `${window.i18n.t('rotations.model_label')} ${modelIndex + 1}${model.name ? ' — ' + escHtmlAttr(model.name) : ''}`;
html += subPanel(`rot:${rotationKey}:prov:${providerIndex}:mdl:${modelIndex}`, mTitle, modelBody, {nested: true});
}); });
container.innerHTML = html;
} }
function updateGlobalNotify(value) { function updateGlobalNotify(value) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment