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
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
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__ = [
# Config
"config",
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
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"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -106,7 +106,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.74",
version="0.99.75",
author="AISBF Contributors",
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",
......
......@@ -898,6 +898,97 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
{% 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="content{% if studio_body_mode == 'wide' %} content-wide{% endif %}">
{% block content %}{% endblock %}
......
......@@ -56,7 +56,15 @@ let autoselectConfig = autoselectData.config;
let availableRotations = autoselectData.rotations;
let availableModels = autoselectData.models;
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 _autoselectDS = null;
......@@ -349,6 +357,7 @@ function toggleAutoselect(key) {
} else {
expandedAutoselects.add(key);
}
_saveExpandedAutoselects();
renderAutoselectList();
}
......@@ -393,20 +402,7 @@ function renderAutoselectDetails(autoselectKey) {
).join('');
fallbackOptions += '</optgroup>';
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.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>
const classificationInner = `
<div class="form-group">
<label>
<input type="checkbox" ${autoselect.classify_nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_nsfw', this.checked)">
......@@ -430,8 +426,9 @@ function renderAutoselectDetails(autoselectKey) {
</label>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small>
</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('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)`)}
......@@ -441,30 +438,13 @@ function renderAutoselectDetails(autoselectKey) {
${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)`)}
<div class="form-group">
<div class="form-group" style="margin-top: 15px;">
<label>${window.i18n.t('autoselect.description')}</label>
<textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea>
</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>
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('autoselect.default_settings')}</h4>
const defaultSettingsInner = `
<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;">
......@@ -507,6 +487,43 @@ function renderAutoselectDetails(autoselectKey) {
<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>
`;
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>
<p style="color: var(--color-muted); font-size: 14px; margin-bottom: 10px;">${window.i18n.t('autoselect.available_models_desc')}</p>
......@@ -531,10 +548,8 @@ function renderAutoselectModels(autoselectKey) {
return;
}
container.innerHTML = '';
let html = '';
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 allOptions = availableRotations.map(r => ({id: r, name: r}));
const selUid = `asel-mod-${autoselectKey}-${index}`;
......@@ -544,12 +559,8 @@ function renderAutoselectModels(autoselectKey) {
const _usageBadge = _modelPid && _providerSupportsUsage(_modelPid)
? `<span data-usage-pid="${_modelPid}" style="margin-left:6px;">${_modelUsage}</span>` : '';
modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; flex-wrap:wrap; gap:6px;">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px;">
<strong>${window.i18n.t('autoselect.model_label')} ${index + 1}</strong>
${_usageBadge}
</div>
const body = `
<div style="display: flex; justify-content: flex-end; align-items: center; margin-bottom: 10px;">
<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>
......@@ -565,11 +576,12 @@ function renderAutoselectModels(autoselectKey) {
<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>
</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) {
......
......@@ -435,7 +435,15 @@ let providersData = {};
const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices | tojson }};
const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_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;
const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = '';
......@@ -938,6 +946,7 @@ function toggleProvider(key) {
const idx = filteredKeys.indexOf(key);
if (idx >= 0) currentProviderPage = Math.floor(idx / PROVIDERS_PAGE_SIZE);
}
_saveExpandedProviders();
renderProvidersList();
}
......@@ -1779,10 +1788,10 @@ function renderProviderDetails(key) {
</select>
</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;">
<h4 style="margin: 0 0 15px 0; color: var(--color-link);">${window.i18n.t('providers.pricing_section')}</h4>
${subPanel(`providers:${key}:pricing`, window.i18n.t('providers.pricing_section'), `
<div style="border-left: 3px solid #4a9eff; padding-left: 12px;">
<div class="form-group">
<label>
......@@ -1874,7 +1883,9 @@ function renderProviderDetails(key) {
</div>
</div>
</div>
`)}
${subPanel(`providers:${key}:limits`, window.i18n.t('providers.rate_limits_defaults') || 'Rate limits & defaults', `
<div class="form-group">
<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">
......@@ -1918,8 +1929,9 @@ function renderProviderDetails(key) {
<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">
</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>
${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.')}
......@@ -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('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.')}
`)}
<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;">
Provider-native caching features (Anthropic cache_control, Google Context Caching, OpenAI and Kilo-compatible APIs) for cost reduction.
</small>
......@@ -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">
<small style="color: var(--color-muted); display: block; margin-top: 5px;">Optional cache key for OpenAI/Kilo load balancer routing optimization</small>
</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;">
Configure specific models for this provider, or leave empty to automatically fetch all available models from the provider's API.
</small>
......@@ -1983,6 +1997,7 @@ function renderProviderDetails(key) {
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')">${window.i18n.t('providers.add_model')}</button>
</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;">
<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) {
return;
}
container.innerHTML = '';
let _modelsHtml = '';
provider.models.forEach((model, index) => {
const selectedStudioCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
const inferredCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
......@@ -2039,25 +2054,7 @@ function renderModels(providerKey) {
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>`
: '';
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);';
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>
const advancedInner = `
<div class="form-group">
<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">
......@@ -2135,8 +2132,28 @@ function renderModels(providerKey) {
</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() {
......
......@@ -64,7 +64,15 @@ let availableProviders = rotationsData.providers;
// instead of a real provider. The model "name" then holds the target id.
const META_PROVIDERS = ['rotations', 'autoselect'];
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 _rotationDS = null;
......@@ -350,6 +358,7 @@ function toggleRotation(key) {
} else {
expandedRotations.add(key);
}
_saveExpandedRotations();
renderRotationsList();
}
......@@ -366,6 +375,28 @@ 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>`
: '';
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 = `
<div class="form-group">
<label>${window.i18n.t('rotations.model_name')}</label>
......@@ -387,25 +418,7 @@ function renderRotationDetails(rotationKey) {
${partialCapsHtml}
</div>
<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: 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)`)}
${subPanel(`rot:${rotationKey}:advanced`, 'Advanced configuration', advancedInner)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('rotations.providers_section')}</h4>
<div id="providers-${rotationKey}"></div>
......@@ -429,29 +442,22 @@ function renderRotationProviders(rotationKey) {
return;
}
container.innerHTML = '';
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, "\\'");
let html = '';
rotation.providers.forEach((provider, providerIndex) => {
const provSelUid = `prov-sel-${rotationKey}-${providerIndex}`;
const _pid = provider.provider_id || '';
const _usageBadge = _providerSupportsUsage(_pid)
? `<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 = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; flex-wrap: wrap; gap: 6px;">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px;">
<strong>${window.i18n.t('rotations.provider_label')} ${providerIndex + 1}</strong>
${_usageBadge}
</div>
<div style="display:flex;align-items:center;gap:6px;">
const body = `
<div style="display:flex; justify-content:flex-end; align-items:center; gap:6px; margin-bottom:10px;">
${(_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 class="form-group">
<label>${window.i18n.t('rotations.provider_id')}</label>
......@@ -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>
`;
container.appendChild(providerDiv);
renderRotationModels(rotationKey, providerIndex);
const titleLabel = `${window.i18n.t('rotations.provider_label')} ${providerIndex + 1}${_pid ? ' — ' + escHtmlAttr(_pid) : ''}`;
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();
}
......@@ -544,10 +553,8 @@ function renderRotationModels(rotationKey, providerIndex) {
return;
}
container.innerHTML = '';
let html = '';
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 inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`;
const providerId = provider.provider_id || '';
......@@ -570,35 +577,7 @@ function renderRotationModels(rotationKey, providerIndex) {
onclick="msOpenProviderForInput(this)"
style="padding:4px 8px;font-size:11px;white-space:nowrap;">${window.i18n.t('rotations.search')}</button>`;
modelDiv.innerHTML = `
<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>
const advancedInner = `
<div class="form-group" style="margin-bottom: 8px;">
<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;">
......@@ -614,7 +593,7 @@ function renderRotationModels(rotationKey, providerIndex) {
<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 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>
<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>
......@@ -631,8 +610,41 @@ function renderRotationModels(rotationKey, providerIndex) {
</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) {
......
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