Commit b92c6b3d authored by Your Name's avatar Your Name

feat: align user dashboard templates with admin, separate user/admin config visibility

- Make user_rotations.html and user_autoselects.html identical to admin templates
- Remove global config fallback for database users in handlers
- Separate provider/rotation visibility: users only see their own, admin only global
- Update version to 0.99.38
parent 2b07302d
...@@ -167,3 +167,4 @@ Thumbs.db ...@@ -167,3 +167,4 @@ Thumbs.db
# Worktrees # Worktrees
.worktrees/ .worktrees/
docs/superpowers/
...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -54,7 +54,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.36" __version__ = "0.99.38"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
......
...@@ -1971,10 +1971,15 @@ class RotationHandler: ...@@ -1971,10 +1971,15 @@ class RotationHandler:
# Load user-specific configs if user_id is provided # Load user-specific configs if user_id is provided
if user_id: if user_id:
self._load_user_configs() self._load_user_configs()
# Override config to only use user-specific configs with NO global fallback
self.rotations = {}
for rotation in self.user_rotations:
self.rotations[rotation['rotation_id']] = rotation['config']
else: else:
self.user_providers = {} self.user_providers = {}
self.user_rotations = {} self.user_rotations = {}
self.user_autoselects = {} self.user_autoselects = {}
self.rotations = self.config.rotations if hasattr(self.config, 'rotations') else {}
def _load_user_configs(self): def _load_user_configs(self):
"""Load user-specific configurations from database""" """Load user-specific configurations from database"""
...@@ -1988,16 +1993,10 @@ class RotationHandler: ...@@ -1988,16 +1993,10 @@ class RotationHandler:
"""Reload user-specific configurations from database""" """Reload user-specific configurations from database"""
if self.user_id: if self.user_id:
self._load_user_configs() self._load_user_configs()
# Refresh rotations dict after reload
def reload_user_configs(self): self.rotations = {}
"""Reload user-specific configurations from database""" for rotation in self.user_rotations:
if self.user_id: self.rotations[rotation['rotation_id']] = rotation['config']
self._load_user_configs()
def reload_user_configs(self):
"""Reload user-specific configurations from database"""
if self.user_id:
self._load_user_configs()
def _get_provider_type(self, provider_id: str) -> str: def _get_provider_type(self, provider_id: str) -> str:
"""Get the provider type from configuration""" """Get the provider type from configuration"""
...@@ -2101,7 +2100,9 @@ class RotationHandler: ...@@ -2101,7 +2100,9 @@ class RotationHandler:
model_id = model_config.get('model_id') or model_config.get('name') or model_config.get('id', '') model_id = model_config.get('model_id') or model_config.get('name') or model_config.get('id', '')
# Try to get defaults from the referenced rotation (first model in the rotation) # Try to get defaults from the referenced rotation (first model in the rotation)
if model_id in self.config.rotations: if self.user_id and model_id in self.rotations:
rotation_config = self.rotations[model_id]
elif model_id in self.config.rotations:
rotation_config = self.config.rotations[model_id] rotation_config = self.config.rotations[model_id]
# Check each default field # Check each default field
...@@ -2332,10 +2333,16 @@ class RotationHandler: ...@@ -2332,10 +2333,16 @@ class RotationHandler:
logger.info(f"User ID: {self.user_id}") logger.info(f"User ID: {self.user_id}")
# Check for user-specific rotation config first # Check for user-specific rotation config first
if self.user_id and rotation_id in self.user_rotations: if self.user_id:
rotation_config = self.user_rotations[rotation_id] # Database user: ONLY use user-specific configs - NO global fallback
rotation_config = next((rot['config'] for rot in self.user_rotations if rot['rotation_id'] == rotation_id), None)
if rotation_config:
logger.info(f"Using user-specific rotation config for {rotation_id}") logger.info(f"Using user-specific rotation config for {rotation_id}")
else: else:
logger.error(f"User rotation {rotation_id} not found - NO global fallback")
raise HTTPException(status_code=400, detail=f"Rotation {rotation_id} not found for this user")
else:
# Admin user: use global config
rotation_config = self.config.get_rotation(rotation_id) rotation_config = self.config.get_rotation(rotation_id)
logger.info(f"Using global rotation config for {rotation_id}") logger.info(f"Using global rotation config for {rotation_id}")
...@@ -3857,10 +3864,15 @@ class AutoselectHandler: ...@@ -3857,10 +3864,15 @@ class AutoselectHandler:
# Load user-specific configs if user_id is provided # Load user-specific configs if user_id is provided
if user_id: if user_id:
self._load_user_configs() self._load_user_configs()
# Override config to only use user-specific configs with NO global fallback
self.autoselects = {}
for autoselect in self.user_autoselects:
self.autoselects[autoselect['autoselect_id']] = autoselect['config']
else: else:
self.user_providers = {} self.user_providers = {}
self.user_rotations = {} self.user_rotations = {}
self.user_autoselects = {} self.user_autoselects = {}
self.autoselects = self.config.autoselect if hasattr(self.config, 'autoselect') else {}
def _load_user_configs(self): def _load_user_configs(self):
"""Load user-specific configurations from database""" """Load user-specific configurations from database"""
...@@ -3870,6 +3882,15 @@ class AutoselectHandler: ...@@ -3870,6 +3882,15 @@ class AutoselectHandler:
self.user_rotations = db.get_user_rotations(self.user_id) self.user_rotations = db.get_user_rotations(self.user_id)
self.user_autoselects = db.get_user_autoselects(self.user_id) self.user_autoselects = db.get_user_autoselects(self.user_id)
def reload_user_configs(self):
"""Reload user-specific configurations from database"""
if self.user_id:
self._load_user_configs()
# Refresh autoselects dict after reload
self.autoselects = {}
for autoselect in self.user_autoselects:
self.autoselects[autoselect['autoselect_id']] = autoselect['config']
def _get_skill_file_content(self) -> str: def _get_skill_file_content(self) -> str:
"""Load the autoselect.md skill file content""" """Load the autoselect.md skill file content"""
if self._skill_file_content is None: if self._skill_file_content is None:
...@@ -4158,9 +4179,9 @@ class AutoselectHandler: ...@@ -4158,9 +4179,9 @@ class AutoselectHandler:
return model_id return model_id
# Check if it's a rotation # Check if it's a rotation
elif selection_model in self.config.rotations: elif (self.user_id and selection_model in self.rotations) or selection_model in self.config.rotations:
logger.info(f"Selection model '{selection_model}' is a rotation") logger.info(f"Selection model '{selection_model}' is a rotation")
rotation_handler = RotationHandler() rotation_handler = RotationHandler(user_id=self.user_id)
response = await rotation_handler.handle_rotation_request(selection_model, selection_request) response = await rotation_handler.handle_rotation_request(selection_model, selection_request)
# Check if it's a provider/model format (e.g., "gemini/gemini-pro") # Check if it's a provider/model format (e.g., "gemini/gemini-pro")
elif '/' in selection_model: elif '/' in selection_model:
...@@ -4255,10 +4276,16 @@ class AutoselectHandler: ...@@ -4255,10 +4276,16 @@ class AutoselectHandler:
logger.warning(f"Response cache check failed: {cache_error}") logger.warning(f"Response cache check failed: {cache_error}")
# Check for user-specific autoselect config first # Check for user-specific autoselect config first
if self.user_id and autoselect_id in self.user_autoselects: if self.user_id:
autoselect_config = self.user_autoselects[autoselect_id] # Database user: ONLY use user-specific configs - NO global fallback
autoselect_config = next((aut['config'] for aut in self.user_autoselects if aut['autoselect_id'] == autoselect_id), None)
if autoselect_config:
logger.info(f"Using user-specific autoselect config for {autoselect_id}") logger.info(f"Using user-specific autoselect config for {autoselect_id}")
else: else:
logger.error(f"User autoselect {autoselect_id} not found - NO global fallback")
raise HTTPException(status_code=400, detail=f"Autoselect {autoselect_id} not found for this user")
else:
# Admin user: use global config
autoselect_config = self.config.get_autoselect(autoselect_id) autoselect_config = self.config.get_autoselect(autoselect_id)
logger.info(f"Using global autoselect config for {autoselect_id}") logger.info(f"Using global autoselect config for {autoselect_id}")
...@@ -4560,9 +4587,9 @@ class AutoselectHandler: ...@@ -4560,9 +4587,9 @@ class AutoselectHandler:
request_data['stream'] = True request_data['stream'] = True
# Check if it's a rotation first # Check if it's a rotation first
if selected_model_id in self.config.rotations: if (self.user_id and selected_model_id in self.rotations) or selected_model_id in self.config.rotations:
logger.info(f"Proxying streaming request to rotation: {selected_model_id}") logger.info(f"Proxying streaming request to rotation: {selected_model_id}")
rotation_handler = RotationHandler() rotation_handler = RotationHandler(user_id=self.user_id)
response = await rotation_handler.handle_rotation_request(selected_model_id, request_data) response = await rotation_handler.handle_rotation_request(selected_model_id, request_data)
# Check if it's a provider/model format (e.g., "gemini/gemini-pro") # Check if it's a provider/model format (e.g., "gemini/gemini-pro")
elif '/' in selected_model_id: elif '/' in selected_model_id:
......
...@@ -4198,8 +4198,16 @@ async def dashboard_rotations(request: Request): ...@@ -4198,8 +4198,16 @@ async def dashboard_rotations(request: Request):
for rotation in user_rotations: for rotation in user_rotations:
rotations_data["rotations"][rotation['rotation_id']] = rotation['config'] rotations_data["rotations"][rotation['rotation_id']] = rotation['config']
# Get available providers # Get available providers - user-specific for database users
if is_config_admin:
# Admin: use global providers
available_providers = list(config.providers.keys()) if config else [] available_providers = list(config.providers.keys()) if config else []
else:
# Database user: use ONLY their own providers
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
available_providers = [p['provider_id'] for p in user_providers]
# Check for success parameter # Check for success parameter
success = request.query_params.get('success') success = request.query_params.get('success')
...@@ -4218,7 +4226,7 @@ async def dashboard_rotations(request: Request): ...@@ -4218,7 +4226,7 @@ async def dashboard_rotations(request: Request):
} }
) )
else: else:
# Database user: use user template with proper context # Database user: use user template
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="dashboard/user_rotations.html", name="dashboard/user_rotations.html",
...@@ -4226,9 +4234,8 @@ async def dashboard_rotations(request: Request): ...@@ -4226,9 +4234,8 @@ async def dashboard_rotations(request: Request):
"request": request, "request": request,
"session": request.session, "session": request.session,
"__version__": __version__, "__version__": __version__,
"user_rotations_json": json.dumps(rotations_data), "rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers), "available_providers": json.dumps(available_providers),
"user_id": current_user_id,
"success": "Configuration saved successfully!" if success else None "success": "Configuration saved successfully!" if success else None
} }
) )
...@@ -4399,13 +4406,15 @@ async def dashboard_autoselect(request: Request): ...@@ -4399,13 +4406,15 @@ async def dashboard_autoselect(request: Request):
for autoselect in user_autoselects: for autoselect in user_autoselects:
autoselect_data[autoselect['autoselect_id']] = autoselect['config'] autoselect_data[autoselect['autoselect_id']] = autoselect['config']
# Get available rotations # Check for success parameter
available_rotations = list(config.rotations.keys()) if config else [] success = request.query_params.get('success')
# Get available provider models if is_config_admin:
# Admin: use global rotations and providers
available_rotations = list(config.rotations.keys()) if config else []
available_models = [] available_models = []
# Add rotation IDs # Add global rotation IDs
for rotation_id in available_rotations: for rotation_id in available_rotations:
available_models.append({ available_models.append({
'id': rotation_id, 'id': rotation_id,
...@@ -4413,7 +4422,7 @@ async def dashboard_autoselect(request: Request): ...@@ -4413,7 +4422,7 @@ async def dashboard_autoselect(request: Request):
'type': 'rotation' 'type': 'rotation'
}) })
# Add provider models # Add global provider models
providers_path = Path.home() / '.aisbf' / 'providers.json' providers_path = Path.home() / '.aisbf' / 'providers.json'
if not providers_path.exists(): if not providers_path.exists():
providers_path = Path(__file__).parent / 'config' / 'providers.json' providers_path = Path(__file__).parent / 'config' / 'providers.json'
...@@ -4433,10 +4442,6 @@ async def dashboard_autoselect(request: Request): ...@@ -4433,10 +4442,6 @@ async def dashboard_autoselect(request: Request):
'type': 'provider' 'type': 'provider'
}) })
# Check for success parameter
success = request.query_params.get('success')
if is_config_admin:
# Config admin: use admin template # Config admin: use admin template
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
...@@ -4451,16 +4456,16 @@ async def dashboard_autoselect(request: Request): ...@@ -4451,16 +4456,16 @@ async def dashboard_autoselect(request: Request):
} }
) )
else: else:
# Database user: use user template with proper context # Database user: use ONLY their own rotations and providers
from aisbf.database import get_database from aisbf.database import get_database
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id) user_autoselects = db.get_user_autoselects(current_user_id)
# For database users, get available user rotations # Get only user's own rotations
user_rotations = db.get_user_rotations(current_user_id) user_rotations = db.get_user_rotations(current_user_id)
available_rotations = [rot['rotation_id'] for rot in user_rotations] available_rotations = [rot['rotation_id'] for rot in user_rotations]
# For database users, get available user providers # Get only user's own providers
user_providers = db.get_user_providers(current_user_id) user_providers = db.get_user_providers(current_user_id)
available_models = [] available_models = []
...@@ -4484,6 +4489,7 @@ async def dashboard_autoselect(request: Request): ...@@ -4484,6 +4489,7 @@ async def dashboard_autoselect(request: Request):
'type': 'provider' 'type': 'provider'
}) })
# Database user: use user template
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="dashboard/user_autoselects.html", name="dashboard/user_autoselects.html",
...@@ -4491,7 +4497,7 @@ async def dashboard_autoselect(request: Request): ...@@ -4491,7 +4497,7 @@ async def dashboard_autoselect(request: Request):
"request": request, "request": request,
"session": request.session, "session": request.session,
"__version__": __version__, "__version__": __version__,
"user_autoselects_json": json.dumps(autoselect_data), "autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations), "available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models), "available_models": json.dumps(available_models),
"user_id": current_user_id, "user_id": current_user_id,
......
...@@ -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.36" version = "0.99.38"
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"
......
...@@ -49,7 +49,7 @@ class InstallCommand(_install): ...@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.36", version="0.99.38",
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",
......
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}My Autoselects - AISBF{% endblock %} {% block title %}My Autoselects - AISBF{% endblock %}
{% block content %} {% block content %}
<div class="container"> <h2 style="margin-bottom: 30px;">My Autoselect Configuration</h2>
<h1>My Autoselects</h1>
<p>Manage your personal autoselect configurations</p> {% if success %}
<div class="alert alert-success">{{ success }}</div>
{% if success %} {% endif %}
<div class="alert alert-success">{{ success }}</div>
{% endif %} {% if error %}
<div class="alert alert-error">{{ error }}</div>
{% if error %} {% endif %}
<div class="alert alert-error">{{ error }}</div>
{% endif %} <div id="autoselect-list" style="margin-bottom: 20px;">
<!-- Autoselect list will be rendered here -->
<div class="card">
<h2>Autoselect Configurations</h2>
<div id="autoselects-list">
<!-- Autoselects will be loaded here -->
</div>
<button class="btn btn-primary" onclick="showAddAutoselectModal()">Add New Autoselect</button>
<button class="btn btn-success" onclick="applyChanges()" style="margin-left: 10px;">✓ Apply Changes</button>
</div>
</div> </div>
<!-- Modal for adding/editing autoselects --> <button type="button" class="btn" onclick="addAutoselect()" style="margin-top: 20px;">Add Autoselect</button>
<div id="autoselect-modal" class="modal" style="display: none;"> <div style="display: flex; gap: 10px; margin-top: 20px;">
<div class="modal-content"> <button type="button" class="btn" onclick="saveAutoselect()">Save Configuration</button>
<div class="modal-header"> <a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
<h3 id="modal-title">Add Autoselect</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="autoselect-form">
<div class="form-group">
<label for="autoselect-name">Autoselect Name:</label>
<input type="text" id="autoselect-name" required>
</div>
<div class="form-group">
<label for="autoselect-config">Autoselect Configuration (JSON):</label>
<textarea id="autoselect-config" rows="20" placeholder='{"model_name": "autoselect-name", "description": "My autoselect config", "fallback": "provider/model"}'></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>
</div>
</div>
</div> </div>
<script> <script>
let autoselects = {{ user_autoselects_json | safe }}; const autoselectData = {
let currentEditingIndex = -1; config: {{ autoselect_json | safe }},
rotations: {{ available_rotations | safe }},
function renderAutoselects() { models: {{ available_models | safe }},
const container = document.getElementById('autoselects-list'); saveUrl: "{{ url_for(request, '/dashboard/autoselect') }}",
successUrl: "{{ url_for(request, '/dashboard/autoselect?success=1') }}"
};
let autoselectConfig = autoselectData.config;
let availableRotations = autoselectData.rotations;
let availableModels = autoselectData.models;
let expandedAutoselects = new Set();
function renderAutoselectList() {
const container = document.getElementById('autoselect-list');
container.innerHTML = ''; container.innerHTML = '';
if (autoselects.length === 0) { if (!autoselectConfig || Object.keys(autoselectConfig).length === 0) {
container.innerHTML = '<p class="empty-state">No autoselects configured yet. Click "Add New Autoselect" to get started.</p>'; container.innerHTML = '<p style="color: #a0a0a0;">No autoselect configurations defined</p>';
return; return;
} }
autoselects.forEach((autoselect, index) => { Object.entries(autoselectConfig).forEach(([key, autoselect]) => {
const div = document.createElement('div'); const autoselectItem = document.createElement('div');
div.className = 'autoselect-item'; autoselectItem.className = 'autoselect-item';
div.innerHTML = ` autoselectItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;';
<div class="autoselect-header">
<h3>${autoselect.autoselect_id}</h3> const isExpanded = expandedAutoselects.has(key);
<div class="autoselect-actions"> const modelCount = autoselect.available_models ? autoselect.available_models.length : 0;
<button class="btn btn-secondary btn-sm" onclick="editAutoselect(${index})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteAutoselect('${autoselect.autoselect_id}')">Delete</button> autoselectItem.innerHTML = `
<div class="autoselect-header" onclick="toggleAutoselect('${key}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${autoselect.model_name || key}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span>
</div> </div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div> </div>
<div class="autoselect-details"> <div id="autoselect-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<p><strong>Created:</strong> ${new Date(autoselect.created_at).toLocaleString()}</p> <!-- Details will be rendered here -->
<p><strong>Last Updated:</strong> ${new Date(autoselect.updated_at).toLocaleString()}</p>
<details>
<summary>Configuration (JSON)</summary>
<pre>${JSON.stringify(autoselect.config, null, 2)}</pre>
</details>
</div> </div>
`; `;
container.appendChild(div);
container.appendChild(autoselectItem);
if (isExpanded) {
renderAutoselectDetails(key);
}
}); });
} }
function showAddAutoselectModal() { function toggleAutoselect(key) {
currentEditingIndex = -1; if (expandedAutoselects.has(key)) {
document.getElementById('modal-title').textContent = 'Add Autoselect'; expandedAutoselects.delete(key);
document.getElementById('autoselect-name').value = ''; } else {
document.getElementById('autoselect-config').value = '{"model_name": "my-autoselect", "description": "My autoselect configuration", "fallback": "provider/model"}'; expandedAutoselects.add(key);
document.getElementById('autoselect-modal').style.display = 'block'; }
renderAutoselectList();
} }
function editAutoselect(index) { function renderAutoselectDetails(autoselectKey) {
currentEditingIndex = index; const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselects[index]; const autoselect = autoselectConfig[autoselectKey];
document.getElementById('modal-title').textContent = 'Edit Autoselect';
document.getElementById('autoselect-name').value = autoselect.autoselect_id; // Default to "internal" if selection_model is not set
document.getElementById('autoselect-name').readOnly = true; const selectionValue = autoselect.selection_model || 'internal';
document.getElementById('autoselect-config').value = JSON.stringify(autoselect.config, null, 2);
document.getElementById('autoselect-modal').style.display = 'block'; // Build selection model options: internal + rotations + provider models
} let selectionOptions = `<option value="internal" ${selectionValue === 'internal' ? 'selected' : ''}>internal (Use configured internal model)</option>`;
selectionOptions += '<optgroup label="Rotations">';
selectionOptions += availableRotations.map(r =>
`<option value="${r}" ${selectionValue === r ? 'selected' : ''}>${r}</option>`
).join('');
selectionOptions += '</optgroup>';
selectionOptions += '<optgroup label="Provider Models">';
selectionOptions += availableModels.map(m =>
`<option value="${m.id}" ${selectionValue === m.id ? 'selected' : ''}>${m.name}</option>`
).join('');
selectionOptions += '</optgroup>';
// Build fallback options: rotations + provider models
let fallbackOptions = '<optgroup label="Rotations">';
fallbackOptions += availableRotations.map(r =>
`<option value="${r}" ${autoselect.fallback === r ? 'selected' : ''}>${r}</option>`
).join('');
fallbackOptions += '</optgroup>';
fallbackOptions += '<optgroup label="Provider Models">';
fallbackOptions += availableModels.map(m =>
`<option value="${m.id}" ${autoselect.fallback === m.id ? 'selected' : ''}>${m.name}</option>`
).join('');
fallbackOptions += '</optgroup>';
container.innerHTML = `
<div class="form-group">
<label>Model Name</label>
<input type="text" value="${autoselect.model_name}" onchange="updateAutoselect('${autoselectKey}', 'model_name', this.value)" required>
</div>
function closeModal() { <div class="form-group">
document.getElementById('autoselect-modal').style.display = 'none'; <label>Capabilities (comma-separated)</label>
document.getElementById('autoselect-name').readOnly = false; <input type="text" value="${autoselect.capabilities ? autoselect.capabilities.join(', ') : ''}" onchange="updateAutoselectCapabilities('${autoselectKey}', this.value)" placeholder="e.g., t2t, reasoning, multimodal">
currentEditingIndex = -1; </div>
}
function deleteAutoselect(autoselectName) { <div class="form-group">
if (!confirm(`Are you sure you want to delete the autoselect "${autoselectName}"? This action cannot be undone.`)) return; <label>
<input type="checkbox" ${autoselect.nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'nsfw', this.checked)">
NSFW
</label>
</div>
fetch('{{ url_for(request, "/dashboard/user/autoselects") }}/' + encodeURIComponent(autoselectName), { <div class="form-group">
method: 'DELETE', <label>
headers: { <input type="checkbox" ${autoselect.privacy ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'privacy', this.checked)">
'Content-Type': 'application/json' Privacy
} </label>
}).then(response => { </div>
if (response.ok) {
location.reload();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to delete autoselect');
});
}
}).catch(error => {
alert('Error: ' + error.message);
});
}
// Handle form submission <div class="form-group">
document.getElementById('autoselect-form').addEventListener('submit', function(e) { <label>
e.preventDefault(); <input type="checkbox" ${autoselect.classify_nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_nsfw', this.checked)">
Classify NSFW
</label>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Override global classify_nsfw setting for this autoselection</small>
</div>
const autoselectName = document.getElementById('autoselect-name').value.trim(); <div class="form-group">
const configText = document.getElementById('autoselect-config').value.trim(); <label>
<input type="checkbox" ${autoselect.classify_privacy ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_privacy', this.checked)">
Classify Privacy
</label>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Override global classify_privacy setting for this autoselection</small>
</div>
if (!autoselectName) { <div class="form-group">
alert('Autoselect name is required'); <label>
return; <input type="checkbox" ${autoselect.classify_semantic ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_semantic', this.checked)">
} Classify Semantic
</label>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Override global classify_semantic setting for this autoselection</small>
</div>
if (!configText) { <div class="form-group">
alert('Autoselect configuration is required'); <label>Description</label>
return; <textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea>
} </div>
let configObj; <div class="form-group">
try { <label>Selection Model (Model to use for analysis)</label>
configObj = JSON.parse(configText); <select onchange="updateAutoselect('${autoselectKey}', 'selection_model', this.value)" required>
} catch (e) { <option value="">Select model...</option>
alert('Invalid JSON configuration: ' + e.message); ${selectionOptions}
</select>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose "internal" to use the configured internal model, or select a rotation/provider model</small>
</div>
<div class="form-group">
<label>Fallback Model (Default if selection fails)</label>
<select onchange="updateAutoselect('${autoselectKey}', 'fallback', this.value)" required>
<option value="">Select model...</option>
${fallbackOptions}
</select>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Default Settings</h4>
<p style="color: #a0a0a0; font-size: 14px; margin-bottom: 10px;">Default values for models in this autoselect (optional - auto-derived from first model if not set)</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div class="form-group">
<label>Default Rate Limit (seconds)</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="Optional">
</div>
<div class="form-group">
<label>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="Optional">
</div>
<div class="form-group">
<label>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="Optional">
</div>
<div class="form-group">
<label>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="Optional">
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Tokens per minute limit</small>
</div>
<div class="form-group">
<label>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="Optional">
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Tokens per hour limit</small>
</div>
<div class="form-group">
<label>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="Optional">
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Tokens per day limit</small>
</div>
<div class="form-group">
<label>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="Optional">
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Trigger context condensation at this token count</small>
</div>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Available Models</h4>
<p style="color: #a0a0a0; font-size: 14px; margin-bottom: 10px;">Define which models can be selected and their descriptions for AI analysis</p>
<div id="models-${autoselectKey}"></div>
<button type="button" class="btn btn-secondary" onclick="addAutoselectModel('${autoselectKey}')" style="margin-top: 10px;">Add Model</button>
`;
renderAutoselectModels(autoselectKey);
}
function renderAutoselectModels(autoselectKey) {
const container = document.getElementById(`models-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey];
if (!autoselect.available_models || autoselect.available_models.length === 0) {
container.innerHTML = '<p style="color: #a0a0a0;">No models configured</p>';
return; return;
} }
const formData = new FormData(); container.innerHTML = '';
formData.append('autoselect_name', autoselectName); autoselect.available_models.forEach((model, index) => {
formData.append('autoselect_config', JSON.stringify(configObj)); const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
const modelOptions = availableModels.map(m =>
`<option value="${m.id}" ${model.model_id === m.id ? 'selected' : ''}>${m.name}</option>`
).join('');
modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong>Model ${index + 1}</strong>
<button type="button" class="btn btn-secondary" onclick="removeAutoselectModel('${autoselectKey}', ${index})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">Remove</button>
</div>
const url = '{{ url_for(request, "/dashboard/user/autoselects") }}'; <div class="form-group">
const method = currentEditingIndex >= 0 ? 'PUT' : 'POST'; <label>Model ID (Rotation or Provider Model)</label>
<select onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'model_id', this.value)" required>
<option value="">Select model...</option>
${modelOptions}
</select>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small>
</div>
fetch(url, { <div class="form-group">
method: method, <label>Description (Used by AI to select appropriate model)</label>
body: formData <textarea onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'description', this.value)" style="min-height: 80px;" required>${model.description || ''}</textarea>
}).then(response => { <small style="color: #666; font-size: 12px;">Be specific about when this model should be used (e.g., "Best for programming, code generation, debugging")</small>
if (response.ok) { </div>
closeModal();
location.reload();
} else {
return response.text().then(text => {
throw new Error(text || 'Failed to save autoselect');
});
}
}).catch(error => {
alert('Error: ' + error.message);
});
});
async function applyChanges() { <div class="form-group">
const button = event.target; <label>
const originalText = button.innerHTML; <input type="checkbox" ${model.nsfw ? 'checked' : ''} onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'nsfw', this.checked)">
NSFW
</label>
</div>
try { <div class="form-group">
button.innerHTML = '🔄 Reloading...'; <label>
button.disabled = true; <input type="checkbox" ${model.privacy ? 'checked' : ''} onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'privacy', this.checked)">
Privacy
</label>
</div>
`;
const response = await fetch('{{ url_for(request, "/dashboard/user/reload-config") }}', { container.appendChild(modelDiv);
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}); });
}
if (response.ok) { function addAutoselect() {
button.innerHTML = '✓ Applied Successfully!'; const key = prompt('Enter autoselect key (e.g., "autoselect", "smart-select"):');
button.style.background = '#10b981'; if (!key) {
return;
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
} else {
const data = await response.json();
throw new Error(data.error || 'Failed to apply changes');
} }
} catch (error) {
button.innerHTML = '✗ Error';
button.style.background = '#ef4444';
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
alert('Error applying changes: ' + error.message); if (autoselectConfig[key]) {
alert('Autoselect key already exists');
return;
} }
}
renderAutoselects(); autoselectConfig[key] = {
</script> model_name: key,
description: '',
selection_model: '',
fallback: '',
available_models: []
};
<style> expandedAutoselects.add(key);
.autoselect-item { renderAutoselectList();
background: #1a1a2e;
padding: 1rem;
margin: 1rem 0;
border-radius: 8px;
} }
.autoselect-header { function removeAutoselect(key) {
display: flex; if (confirm(`Remove autoselect "${key}"?`)) {
justify-content: space-between; delete autoselectConfig[key];
align-items: center; expandedAutoselects.delete(key);
margin-bottom: 1rem; renderAutoselectList();
}
} }
.autoselect-header h3 { function updateAutoselect(key, field, value) {
margin: 0; autoselectConfig[key][field] = value;
} }
.autoselect-actions { function updateAutoselectCapabilities(autoselectKey, value) {
display: flex; const trimmed = value.trim();
gap: 0.5rem; if (!trimmed) {
} autoselectConfig[autoselectKey].capabilities = null;
return;
}
.autoselect-details p { // Split by comma and clean up
margin: 0.5rem 0; autoselectConfig[autoselectKey].capabilities =
trimmed.split(',').map(s => s.trim()).filter(s => s);
} }
.autoselect-details pre { function addAutoselectModel(autoselectKey) {
background: #0f3460; if (!autoselectConfig[autoselectKey].available_models) {
padding: 1rem; autoselectConfig[autoselectKey].available_models = [];
border-radius: 4px; }
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.empty-state { autoselectConfig[autoselectKey].available_models.push({
text-align: center; model_id: '',
color: #a0a0a0; description: ''
padding: 2rem; });
renderAutoselectModels(autoselectKey);
} }
.modal { function removeAutoselectModel(autoselectKey, index) {
position: fixed; if (confirm('Remove this model?')) {
top: 0; autoselectConfig[autoselectKey].available_models.splice(index, 1);
left: 0; renderAutoselectModels(autoselectKey);
width: 100%; }
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
} }
.modal-content { function updateAutoselectModel(autoselectKey, index, field, value) {
background: #1a1a2e; autoselectConfig[autoselectKey].available_models[index][field] = value;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
} }
.modal-header { async function saveAutoselect() {
display: flex; try {
justify-content: space-between; const response = await fetch(autoselectData.saveUrl, {
align-items: center; method: 'POST',
padding: 1rem; headers: {
border-bottom: 1px solid #0f3460; 'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'config=' + encodeURIComponent(JSON.stringify(autoselectConfig, null, 2))
});
if (response.ok) {
window.location.href = autoselectData.successUrl;
} else {
alert('Error saving configuration');
}
} catch (error) {
alert('Error: ' + error.message);
}
} }
.modal-header h3 { // Initial render
margin: 0; renderAutoselectList();
</script>
<style>
.autoselect-item {
animation: fadeIn 0.3s;
} }
.close-btn { .autoselect-header:hover {
background: none; background: #0f3460;
border: none;
color: #e0e0e0;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
} }
.modal-body { @keyframes fadeIn {
padding: 1rem; from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
} }
.form-group { .form-group {
margin-bottom: 1rem; margin-bottom: 15px;
} }
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 5px;
font-weight: 500; font-weight: 500;
color: #e0e0e0;
} }
.form-group input[type="text"], .form-group input[type="text"],
.form-group textarea { .form-group textarea,
.form-group select {
width: 100%; width: 100%;
padding: 0.5rem; padding: 8px;
border: 1px solid #0f3460; border: 1px solid #ddd;
border-radius: 4px; border-radius: 3px;
background: #16213e;
color: #e0e0e0;
font-family: monospace;
font-size: 14px; font-size: 14px;
} }
.form-group textarea { .form-group input[type="checkbox"] {
resize: vertical; margin-right: 5px;
min-height: 300px;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
} }
</style> </style>
{% endblock %} {% endblock %}
<!--
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}My Rotations - AISBF{% endblock %} {% block title %}My Rotations - AISBF{% endblock %}
{% block content %} {% block content %}
<div class="container"> <h2 style="margin-bottom: 30px;">My Rotations Configuration</h2>
<h1>My Rotations</h1>
<p>Manage your personal rotation configurations</p> {% if success %}
<div class="alert alert-success">{{ success }}</div>
{% if success %} {% endif %}
<div class="alert alert-success">{{ success }}</div>
{% endif %} {% if error %}
<div class="alert alert-error">{{ error }}</div>
{% if error %} {% endif %}
<div class="alert alert-error">{{ error }}</div>
{% endif %} <div id="rotations-list" style="margin-bottom: 20px;">
<!-- Rotations list will be rendered here -->
<div class="card"> </div>
<h2>Rotation Configurations</h2>
<div id="rotations-list"> <button type="button" class="btn" onclick="addRotation()" style="margin-top: 20px;">Add Rotation</button>
<!-- Rotations will be loaded here --> <div style="display: flex; gap: 10px; margin-top: 20px;">
<button type="button" class="btn" onclick="saveRotations()">Save Configuration</button>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
<script>
const rotationsData = {
config: {{ rotations_json | safe }},
providers: {{ available_providers | safe }},
saveUrl: "{{ url_for(request, '/dashboard/rotations') }}",
successUrl: "{{ url_for(request, '/dashboard/rotations?success=1') }}"
};
let rotationsConfig = rotationsData.config;
let availableProviders = rotationsData.providers;
let expandedRotations = new Set();
function renderRotationsList() {
const container = document.getElementById('rotations-list');
container.innerHTML = '';
Object.entries(rotationsConfig.rotations || {}).forEach(([key, rotation]) => {
const rotationItem = document.createElement('div');
rotationItem.className = 'rotation-item';
rotationItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;';
const isExpanded = expandedRotations.has(key);
const providerCount = rotation.providers ? rotation.providers.length : 0;
rotationItem.innerHTML = `
<div class="rotation-header" onclick="toggleRotation('${key}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${key}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span>
</div> </div>
<button class="btn btn-primary" onclick="showAddRotationModal()">Add New Rotation</button> <button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
<button class="btn btn-success" onclick="applyChanges()" style="margin-left: 10px;">✓ Apply Changes</button>
</div> </div>
</div> <div id="rotation-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
</div>
`;
container.appendChild(rotationItem);
<!-- Modal for adding/editing rotations --> if (isExpanded) {
<div id="rotation-modal" class="modal" style="display: none;"> renderRotationDetails(key);
<div class="modal-content"> }
<div class="modal-header"> });
<h3 id="modal-title">Add Rotation</h3> }
<button class="close-btn" onclick="closeModal()">&times;</button>
function toggleRotation(key) {
if (expandedRotations.has(key)) {
expandedRotations.delete(key);
} else {
expandedRotations.add(key);
}
renderRotationsList();
}
function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey];
container.innerHTML = `
<div class="form-group">
<label>Model Name</label>
<input type="text" value="${rotation.model_name}" onchange="updateRotation('${rotationKey}', 'model_name', this.value)" required>
</div> </div>
<div class="modal-body">
<form id="rotation-form">
<div class="form-group"> <div class="form-group">
<label for="rotation-name">Rotation Name:</label> <label>
<input type="text" id="rotation-name" required> <input type="checkbox" ${rotation.notifyerrors ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'notifyerrors', this.checked)">
Notify Errors
</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="rotation-config">Rotation Configuration (JSON):</label> <label>Capabilities (comma-separated)</label>
<textarea id="rotation-config" rows="20" placeholder='{"model_name": "rotation-name", "providers": []}'></textarea> <input type="text" value="${rotation.capabilities ? rotation.capabilities.join(', ') : ''}" onchange="updateRotationCapabilities('${rotationKey}', this.value)" placeholder="e.g., code_generation, t2t, reasoning">
</div> </div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button> <div class="form-group">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button> <label>Default Rate Limit (seconds)</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="Optional">
</div> </div>
</form>
<div class="form-group">
<label>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="Optional">
</div> </div>
<div class="form-group">
<label>
<input type="checkbox" ${rotation.nsfw ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'nsfw', this.checked)">
NSFW
</label>
</div> </div>
</div>
<script> <div class="form-group">
let rotations = {{ user_rotations_json | safe }}; <label>
let currentEditingIndex = -1; <input type="checkbox" ${rotation.privacy ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'privacy', this.checked)">
Privacy
</label>
</div>
function renderRotations() { <h4 style="margin-top: 20px; margin-bottom: 10px;">Providers</h4>
const container = document.getElementById('rotations-list'); <div id="providers-${rotationKey}"></div>
container.innerHTML = ''; <button type="button" class="btn btn-secondary" onclick="addRotationProvider('${rotationKey}')" style="margin-top: 10px;">Add Provider</button>
`;
if (rotations.length === 0) { renderRotationProviders(rotationKey);
container.innerHTML = '<p class="empty-state">No rotations configured yet. Click "Add New Rotation" to get started.</p>'; }
function renderRotationProviders(rotationKey) {
const container = document.getElementById(`providers-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey];
if (!rotation.providers || rotation.providers.length === 0) {
container.innerHTML = '<p style="color: #a0a0a0;">No providers configured</p>';
return; return;
} }
rotations.forEach((rotation, index) => { container.innerHTML = '';
const div = document.createElement('div'); rotation.providers.forEach((provider, providerIndex) => {
div.className = 'rotation-item'; const providerDiv = document.createElement('div');
div.innerHTML = ` providerDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
<div class="rotation-header">
<h3>${rotation.rotation_id}</h3> const providerOptions = availableProviders.map(p =>
<div class="rotation-actions"> `<option value="${p}" ${provider.provider_id === p ? 'selected' : ''}>${p}</option>`
<button class="btn btn-secondary btn-sm" onclick="editRotation(${index})">Edit</button> ).join('');
<button class="btn btn-danger btn-sm" onclick="deleteRotation('${rotation.rotation_id}')">Delete</button>
providerDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong>Provider ${providerIndex + 1}</strong>
<button type="button" class="btn btn-secondary" onclick="removeRotationProvider('${rotationKey}', ${providerIndex})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">Remove</button>
</div> </div>
<div class="form-group">
<label>Provider ID</label>
<select onchange="updateRotationProvider('${rotationKey}', ${providerIndex}, 'provider_id', this.value)" required>
<option value="">Select provider...</option>
${providerOptions}
</select>
</div> </div>
<div class="rotation-details">
<p><strong>Created:</strong> ${new Date(rotation.created_at).toLocaleString()}</p> <div class="form-group">
<p><strong>Last Updated:</strong> ${new Date(rotation.updated_at).toLocaleString()}</p> <label>Weight (optional, for provider-level weight)</label>
<details> <input type="number" value="${provider.weight || ''}" onchange="updateRotationProvider('${rotationKey}', ${providerIndex}, 'weight', this.value ? parseInt(this.value) : null)" placeholder="Optional">
<summary>Configuration (JSON)</summary>
<pre>${JSON.stringify(rotation.config, null, 2)}</pre>
</details>
</div> </div>
<h5 style="margin-top: 15px; margin-bottom: 10px;">Models</h5>
<div id="models-${rotationKey}-${providerIndex}"></div>
<button type="button" class="btn btn-secondary" onclick="addRotationModel('${rotationKey}', ${providerIndex})" style="margin-top: 10px; font-size: 12px;">Add Model</button>
<p style="font-size: 12px; color: #a0a0a0; margin-top: 5px;">Leave models empty to use all models from provider config</p>
`; `;
container.appendChild(div);
container.appendChild(providerDiv);
renderRotationModels(rotationKey, providerIndex);
}); });
} }
function showAddRotationModal() { function renderRotationModels(rotationKey, providerIndex) {
currentEditingIndex = -1; const container = document.getElementById(`models-${rotationKey}-${providerIndex}`);
document.getElementById('modal-title').textContent = 'Add Rotation'; const provider = rotationsConfig.rotations[rotationKey].providers[providerIndex];
document.getElementById('rotation-name').value = '';
document.getElementById('rotation-config').value = '{"model_name": "my-rotation", "providers": []}';
document.getElementById('rotation-modal').style.display = 'block';
}
function editRotation(index) { if (!provider.models || provider.models.length === 0) {
currentEditingIndex = index; container.innerHTML = '<p style="color: #a0a0a0; font-size: 12px;">No models specified (will use all from provider)</p>';
const rotation = rotations[index]; return;
document.getElementById('modal-title').textContent = 'Edit Rotation'; }
document.getElementById('rotation-name').value = rotation.rotation_id;
document.getElementById('rotation-name').readOnly = true;
document.getElementById('rotation-config').value = JSON.stringify(rotation.config, null, 2);
document.getElementById('rotation-modal').style.display = 'block';
}
function closeModal() { container.innerHTML = '';
document.getElementById('rotation-modal').style.display = 'none'; provider.models.forEach((model, modelIndex) => {
document.getElementById('rotation-name').readOnly = false; const modelDiv = document.createElement('div');
currentEditingIndex = -1; modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 10px; margin-bottom: 8px; border-radius: 3px; background: #16213e;';
}
modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="font-size: 13px;">Model ${modelIndex + 1}</strong>
<button type="button" onclick="removeRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex})" style="background: #dc3545; color: white; border: none; padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 11px;">Remove</button>
</div>
function deleteRotation(rotationName) { <div class="form-group" style="margin-bottom: 8px;">
if (!confirm(`Are you sure you want to delete the rotation "${rotationName}"? This action cannot be undone.`)) return; <label style="font-size: 12px;">Model Name</label>
<input type="text" value="${model.name}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'name', this.value)" required style="font-size: 12px; padding: 5px;">
</div>
fetch('{{ url_for(request, "/dashboard/user/rotations") }}/' + encodeURIComponent(rotationName), { <div class="form-group" style="margin-bottom: 8px;">
method: 'DELETE', <label style="font-size: 12px;">Weight</label>
headers: { <input type="number" value="${model.weight || 1}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'weight', parseInt(this.value))" style="font-size: 12px; padding: 5px;">
'Content-Type': 'application/json' </div>
}
}).then(response => {
if (response.ok) {
location.reload();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to delete rotation');
});
}
}).catch(error => {
alert('Error: ' + error.message);
});
}
// Handle form submission <div class="form-group" style="margin-bottom: 8px;">
document.getElementById('rotation-form').addEventListener('submit', function(e) { <label style="font-size: 12px;">Rate Limit (seconds)</label>
e.preventDefault(); <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 rotationName = document.getElementById('rotation-name').value.trim(); <div class="form-group" style="margin-bottom: 8px;">
const configText = document.getElementById('rotation-config').value.trim(); <label style="font-size: 12px;">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="Optional" style="font-size: 12px; padding: 5px;">
</div>
if (!rotationName) { <div class="form-group" style="margin-bottom: 8px;">
alert('Rotation name is required'); <label style="font-size: 12px;">Context Size</label>
return; <input type="number" value="${model.context_size || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'context_size', this.value ? parseInt(this.value) : null)" placeholder="Optional" style="font-size: 12px; padding: 5px;">
} </div>
if (!configText) { <div class="form-group" style="margin-bottom: 8px;">
alert('Rotation configuration is required'); <label style="font-size: 12px;">Condense Context (%)</label>
return; <input type="number" value="${model.condense_context || ''}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'condense_context', this.value ? parseInt(this.value) : null)" placeholder="Optional (default: 80)" style="font-size: 12px; padding: 5px;">
} </div>
let configObj; <div class="form-group" style="margin-bottom: 0;">
try { <label style="font-size: 12px;">Condense Method</label>
configObj = JSON.parse(configText); <input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateRotationModelCondenseMethod('${rotationKey}', ${providerIndex}, ${modelIndex}, this.value)" placeholder="e.g., semantic, conversational, hierarchical" style="font-size: 12px; padding: 5px;">
} catch (e) { </div>
alert('Invalid JSON configuration: ' + e.message); `;
return;
}
const formData = new FormData(); container.appendChild(modelDiv);
formData.append('rotation_name', rotationName); });
formData.append('rotation_config', JSON.stringify(configObj)); }
const url = '{{ url_for(request, "/dashboard/user/rotations") }}'; function addRotation() {
const method = currentEditingIndex >= 0 ? 'PUT' : 'POST'; const key = prompt('Enter rotation key (e.g., "coding", "general"):');
if (!key || rotationsConfig.rotations[key]) {
alert('Invalid or duplicate rotation key');
return;
}
fetch(url, { if (!rotationsConfig.rotations) {
method: method, rotationsConfig.rotations = {};
body: formData
}).then(response => {
if (response.ok) {
closeModal();
location.reload();
} else {
return response.text().then(text => {
throw new Error(text || 'Failed to save rotation');
});
} }
}).catch(error => {
alert('Error: ' + error.message);
});
});
async function applyChanges() { rotationsConfig.rotations[key] = {
const button = event.target; model_name: key,
const originalText = button.innerHTML; notifyerrors: false,
capabilities: [],
providers: []
};
try { expandedRotations.add(key);
button.innerHTML = '🔄 Reloading...'; renderRotationsList();
button.disabled = true; }
const response = await fetch('{{ url_for(request, "/dashboard/user/reload-config") }}', { function removeRotation(key) {
method: 'POST', if (confirm(`Remove rotation "${key}"?`)) {
headers: { delete rotationsConfig.rotations[key];
'Content-Type': 'application/json' expandedRotations.delete(key);
renderRotationsList();
} }
}); }
if (response.ok) { function updateRotation(key, field, value) {
button.innerHTML = '✓ Applied Successfully!'; rotationsConfig.rotations[key][field] = value;
button.style.background = '#10b981'; }
setTimeout(() => { function updateRotationCapabilities(rotationKey, value) {
button.innerHTML = originalText; const trimmed = value.trim();
button.style.background = ''; if (!trimmed) {
button.disabled = false; rotationsConfig.rotations[rotationKey].capabilities = null;
}, 2000); return;
} else {
const data = await response.json();
throw new Error(data.error || 'Failed to apply changes');
} }
} catch (error) {
button.innerHTML = '✗ Error';
button.style.background = '#ef4444';
setTimeout(() => { // Split by comma and clean up
button.innerHTML = originalText; rotationsConfig.rotations[rotationKey].capabilities =
button.style.background = ''; trimmed.split(',').map(s => s.trim()).filter(s => s);
button.disabled = false; }
}, 2000);
alert('Error applying changes: ' + error.message); function addRotationProvider(rotationKey) {
if (!rotationsConfig.rotations[rotationKey].providers) {
rotationsConfig.rotations[rotationKey].providers = [];
} }
}
renderRotations(); rotationsConfig.rotations[rotationKey].providers.push({
</script> provider_id: '',
models: []
});
<style> renderRotationProviders(rotationKey);
.rotation-item {
background: #1a1a2e;
padding: 1rem;
margin: 1rem 0;
border-radius: 8px;
} }
.rotation-header { function removeRotationProvider(rotationKey, providerIndex) {
display: flex; if (confirm('Remove this provider?')) {
justify-content: space-between; rotationsConfig.rotations[rotationKey].providers.splice(providerIndex, 1);
align-items: center; renderRotationProviders(rotationKey);
margin-bottom: 1rem; }
} }
.rotation-header h3 { function updateRotationProvider(rotationKey, providerIndex, field, value) {
margin: 0; rotationsConfig.rotations[rotationKey].providers[providerIndex][field] = value;
} }
.rotation-actions { function addRotationModel(rotationKey, providerIndex) {
display: flex; const provider = rotationsConfig.rotations[rotationKey].providers[providerIndex];
gap: 0.5rem; if (!provider.models) {
} provider.models = [];
}
.rotation-details p { provider.models.push({
margin: 0.5rem 0; name: '',
} weight: 1,
rate_limit: 0
});
.rotation-details pre { renderRotationModels(rotationKey, providerIndex);
background: #0f3460;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
} }
.empty-state { function removeRotationModel(rotationKey, providerIndex, modelIndex) {
text-align: center; if (confirm('Remove this model?')) {
color: #a0a0a0; rotationsConfig.rotations[rotationKey].providers[providerIndex].models.splice(modelIndex, 1);
padding: 2rem; renderRotationModels(rotationKey, providerIndex);
}
} }
.modal { function updateRotationModel(rotationKey, providerIndex, modelIndex, field, value) {
position: fixed; rotationsConfig.rotations[rotationKey].providers[providerIndex].models[modelIndex][field] = value;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
} }
.modal-content { function updateRotationModelCondenseMethod(rotationKey, providerIndex, modelIndex, value) {
background: #1a1a2e; const trimmed = value.trim();
border-radius: 8px; if (!trimmed) {
width: 90%; rotationsConfig.rotations[rotationKey].providers[providerIndex].models[modelIndex].condense_method = null;
max-width: 600px; return;
max-height: 80vh; }
overflow-y: auto;
// Check if it's a comma-separated list
if (trimmed.includes(',')) {
rotationsConfig.rotations[rotationKey].providers[providerIndex].models[modelIndex].condense_method =
trimmed.split(',').map(s => s.trim()).filter(s => s);
} else {
rotationsConfig.rotations[rotationKey].providers[providerIndex].models[modelIndex].condense_method = trimmed;
}
} }
.modal-header { async function saveRotations() {
display: flex; try {
justify-content: space-between; const response = await fetch(rotationsData.saveUrl, {
align-items: center; method: 'POST',
padding: 1rem; headers: {
border-bottom: 1px solid #0f3460; 'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'config=' + encodeURIComponent(JSON.stringify(rotationsConfig, null, 2))
});
if (response.ok) {
window.location.href = rotationsData.successUrl;
} else {
alert('Error saving configuration');
}
} catch (error) {
alert('Error: ' + error.message);
}
} }
.modal-header h3 { // Initial render
margin: 0; renderRotationsList();
</script>
<style>
.rotation-item {
animation: fadeIn 0.3s;
} }
.close-btn { .rotation-header:hover {
background: none; background: #0f3460;
border: none;
color: #e0e0e0;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
} }
.modal-body { @keyframes fadeIn {
padding: 1rem; from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
} }
.form-group { .form-group {
margin-bottom: 1rem; margin-bottom: 15px;
} }
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 5px;
font-weight: 500; font-weight: 500;
color: #e0e0e0; color: #e0e0e0;
} }
.form-group input[type="text"], .form-group input[type="text"],
.form-group textarea { .form-group input[type="number"],
.form-group select {
width: 100%; width: 100%;
padding: 0.5rem; padding: 8px;
border: 1px solid #0f3460; border: 1px solid #0f3460;
border-radius: 4px; border-radius: 3px;
background: #16213e;
color: #e0e0e0;
font-family: monospace;
font-size: 14px; font-size: 14px;
background: #1a1a2e;
color: #e0e0e0;
} }
.form-group textarea { .form-group input[type="checkbox"] {
resize: vertical; margin-right: 5px;
min-height: 300px;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
} }
</style> </style>
{% endblock %} {% endblock %}
#!/bin/bash #!/bin/bash
# Release Verification Script for AISBF v0.99.26 # Release Verification Script for AISBF v0.99.37
echo "================================================================================" echo "================================================================================"
echo " AISBF v0.99.26 Release Verification" echo " AISBF v0.99.37 Release Verification"
echo "================================================================================" echo "================================================================================"
echo echo
...@@ -26,7 +26,7 @@ check() { ...@@ -26,7 +26,7 @@ check() {
# 1. Check version numbers # 1. Check version numbers
echo "1. Checking version numbers..." echo "1. Checking version numbers..."
VERSION="0.99.26" VERSION="0.99.37"
grep -q "version=\"$VERSION\"" setup.py grep -q "version=\"$VERSION\"" setup.py
check "setup.py version is $VERSION" check "setup.py version is $VERSION"
......
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