Granular CRUD with hot-reload, pagination, searchable selects, model autocomplete

- Per-item save/delete API endpoints for providers, rotations, autoselects (admin + user)
- Config hot-reload on every change without server restart
- Provider list pagination (10/page) with search/filter
- Searchable datalist selects for >25 providers or models
- Model name autocomplete from provider's configured model list
- Fix JS key-escaping bug that broke rendering with special chars in keys
parent a044de47
......@@ -2987,7 +2987,7 @@ class RotationHandler:
completion_tokens = 0
# Try to extract actual cost from provider response
from ..cost_extractor import extract_cost_from_response
from .cost_extractor import extract_cost_from_response
actual_cost = extract_cost_from_response(response, provider_id)
# Always record analytics
......@@ -4485,7 +4485,7 @@ class AutoselectHandler:
completion_tokens = 0
# Try to extract actual cost from provider response
from ..cost_extractor import extract_cost_from_response
from .cost_extractor import extract_cost_from_response
actual_cost = extract_cost_from_response(response, 'autoselect')
# Always record analytics
......
......@@ -62,6 +62,7 @@ from pathlib import Path
import json
import re
import markdown
import threading
from urllib.parse import urljoin, urlencode
from cryptography.fernet import Fernet
......@@ -369,6 +370,57 @@ def setup_logging():
# Configure logging
logger = setup_logging()
# Thread-safe lock for config reload operations
_config_reload_lock = threading.Lock()
def _reload_global_config():
"""Thread-safe reload of the global config singleton after file changes.
Safe to call from async handlers since config.reload() is fast."""
with _config_reload_lock:
try:
from aisbf.config import config as _global_cfg
if _global_cfg is not None:
_global_cfg.reload()
logger.info("Global config hot-reloaded after dashboard change")
except Exception as _e:
logger.error(f"Error reloading global config: {_e}", exc_info=True)
def _apply_condense_defaults_provider(provider: dict):
"""Apply condense_context=80 default when condense_method is set but condense_context is not."""
for model in provider.get('models', []):
if isinstance(model, dict) and model.get('condense_method') and not model.get('condense_context'):
model['condense_context'] = 80
def _apply_condense_defaults_rotation(rotation: dict):
for prov in rotation.get('providers', []):
for model in prov.get('models', []):
if isinstance(model, dict) and model.get('condense_method') and not model.get('condense_context'):
model['condense_context'] = 80
def _providers_json_path():
p = Path.home() / '.aisbf' / 'providers.json'
if not p.exists():
p = Path(__file__).parent / 'config' / 'providers.json'
return p
def _rotations_json_path():
p = Path.home() / '.aisbf' / 'rotations.json'
if not p.exists():
p = Path(__file__).parent / 'config' / 'rotations.json'
return p
def _autoselect_json_path():
p = Path.home() / '.aisbf' / 'autoselect.json'
if not p.exists():
p = Path(__file__).parent / 'config' / 'autoselect.json'
return p
class ProxyHeadersMiddleware(BaseHTTPMiddleware):
"""
Middleware to handle proxy headers and make the application proxy-aware.
......@@ -4408,7 +4460,7 @@ async def dashboard_providers(request: Request):
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
"success": "Configuration saved successfully!" if success else None
}
)
else:
......@@ -4599,6 +4651,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
......@@ -4620,7 +4673,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
logger.info(f"Saved {len(providers_data)} provider(s) to database for user {current_user_id}")
if is_config_admin:
success_msg = "Configuration saved successfully! Restart server for changes to take effect."
success_msg = "Configuration saved successfully!"
return templates.TemplateResponse(
request=request,
name="dashboard/providers.html",
......@@ -4787,6 +4840,49 @@ async def dashboard_providers_get_models(request: Request):
"error": str(e)
}, status_code=500)
@app.get("/dashboard/providers/{provider_id}/configured-models")
async def get_provider_configured_models(request: Request, provider_id: str, search: str = ""):
"""Return model names from a provider's local config (no external API calls)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"models": []}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
provider = None
if is_config_admin:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f:
full_config = json.load(f)
if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers_data = full_config['providers']
else:
providers_data = {k: v for k, v in full_config.items() if k != 'condensation'}
provider = providers_data.get(provider_id)
else:
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
match = next((p for p in user_providers if p['provider_id'] == provider_id), None)
if match:
provider = match.get('config', match)
if not provider:
return JSONResponse({"models": []})
models = provider.get('models', [])
model_names = [m.get('name', '') if isinstance(m, dict) else str(m) for m in models]
model_names = [n for n in model_names if n]
if search:
search_lower = search.lower()
model_names = [n for n in model_names if search_lower in n.lower()]
return JSONResponse({"models": model_names[:50]})
@app.get("/dashboard/rotations", response_class=HTMLResponse)
async def dashboard_rotations(request: Request):
"""Edit rotations configuration"""
......@@ -4839,7 +4935,7 @@ async def dashboard_rotations(request: Request):
"session": request.session,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
"success": "Configuration saved successfully!" if success else None
}
)
else:
......@@ -4888,6 +4984,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(rotations_data, f, indent=2)
_reload_global_config()
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
......@@ -4921,7 +5018,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
"__version__": __version__,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"success": "Configuration saved successfully! Restart server for changes to take effect."
"success": "Configuration saved successfully!"
}
)
else:
......@@ -5072,7 +5169,7 @@ async def dashboard_autoselect(request: Request):
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
"success": "Configuration saved successfully!" if success else None
}
)
else:
......@@ -5144,6 +5241,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
json.dump(autoselect_data, f, indent=2)
_reload_global_config()
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
......@@ -5207,7 +5305,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"success": "Configuration saved successfully! Restart server for changes to take effect."
"success": "Configuration saved successfully!"
}
)
else:
......@@ -5367,6 +5465,233 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
}
)
# ---------------------------------------------------------------------------
# Granular CRUD endpoints — act on a single provider/rotation/autoselect
# and trigger hot-reload of the in-memory config so no restart is needed.
# ---------------------------------------------------------------------------
@app.post("/dashboard/api/provider")
async def api_provider_save(request: Request):
"""Create or update a single provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
provider_id = body.get('provider_id')
provider_config = body.get('config', {})
if not provider_id:
return JSONResponse({"success": False, "error": "provider_id required"}, status_code=400)
_apply_condense_defaults_provider(provider_config)
if is_config_admin:
config_path = _providers_json_path()
with open(config_path) as f:
full_config = json.load(f)
if 'providers' not in full_config or not isinstance(full_config['providers'], dict):
full_config['providers'] = {}
full_config['providers'][provider_id] = provider_config
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.save_user_provider(current_user_id, provider_id, provider_config)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_provider_save error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@app.delete("/dashboard/api/provider/{provider_id:path}")
async def api_provider_delete(request: Request, provider_id: str):
"""Delete a single provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
if is_config_admin:
config_path = _providers_json_path()
with open(config_path) as f:
full_config = json.load(f)
providers = full_config.get('providers', full_config)
providers.pop(provider_id, None)
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_provider(current_user_id, provider_id)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_provider_delete error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@app.post("/dashboard/api/rotation")
async def api_rotation_save(request: Request):
"""Create or update a single rotation"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
rotation_id = body.get('rotation_id')
rotation_config = body.get('config', {})
if not rotation_id:
return JSONResponse({"success": False, "error": "rotation_id required"}, status_code=400)
_apply_condense_defaults_rotation(rotation_config)
if is_config_admin:
config_path = _rotations_json_path()
with open(config_path) as f:
full_config = json.load(f)
if 'rotations' not in full_config or not isinstance(full_config['rotations'], dict):
full_config['rotations'] = {}
full_config['rotations'][rotation_id] = rotation_config
save_path = Path.home() / '.aisbf' / 'rotations.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.save_user_rotation(current_user_id, rotation_id, rotation_config)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_rotation_save error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@app.delete("/dashboard/api/rotation/{rotation_id:path}")
async def api_rotation_delete(request: Request, rotation_id: str):
"""Delete a single rotation"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
if is_config_admin:
config_path = _rotations_json_path()
with open(config_path) as f:
full_config = json.load(f)
full_config.get('rotations', {}).pop(rotation_id, None)
save_path = Path.home() / '.aisbf' / 'rotations.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_rotation(current_user_id, rotation_id)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_rotation_delete error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@app.post("/dashboard/api/autoselect")
async def api_autoselect_save(request: Request):
"""Create or update a single autoselect entry"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
body = await request.json()
autoselect_id = body.get('autoselect_id')
autoselect_config = body.get('config', {})
if not autoselect_id:
return JSONResponse({"success": False, "error": "autoselect_id required"}, status_code=400)
if is_config_admin:
config_path = _autoselect_json_path()
save_path = Path.home() / '.aisbf' / 'autoselect.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
if config_path.exists():
with open(config_path) as f:
full_config = json.load(f)
else:
full_config = {}
full_config[autoselect_id] = autoselect_config
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.save_user_autoselect(current_user_id, autoselect_id, autoselect_config)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_autoselect_save error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@app.delete("/dashboard/api/autoselect/{autoselect_id:path}")
async def api_autoselect_delete(request: Request, autoselect_id: str):
"""Delete a single autoselect entry"""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Not authenticated"}, status_code=401)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
if is_config_admin:
config_path = _autoselect_json_path()
save_path = Path.home() / '.aisbf' / 'autoselect.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
if config_path.exists():
with open(config_path) as f:
full_config = json.load(f)
else:
full_config = {}
full_config.pop(autoselect_id, None)
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_autoselect(current_user_id, autoselect_id)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"api_autoselect_delete error: {e}", exc_info=True)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@app.get("/dashboard/prompts", response_class=HTMLResponse)
async def dashboard_prompts(request: Request):
"""Edit prompt templates"""
......
......@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
<script>
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const autoselectData = {
config: {{ autoselect_json | safe }},
rotations: {{ available_rotations | safe }},
......@@ -52,6 +56,31 @@ let availableRotations = autoselectData.rotations;
let availableModels = autoselectData.models;
let expandedAutoselects = new Set();
function buildModelSelectHtml(uid, currentValue, allModels, onChangeFn) {
if (allModels.length <= 25) {
const opts = allModels.map(m =>
`<option value="${escHtmlAttr(m.id)}" ${currentValue === m.id ? 'selected' : ''}>${escHtmlAttr(m.name)}</option>`
).join('');
return `<select id="${uid}" onchange="${onChangeFn}(this.value)" required>
<option value="">Select model...</option>${opts}</select>`;
} else {
const dlOpts = allModels.map(m => `<option value="${escHtmlAttr(m.id)}" label="${escHtmlAttr(m.name)}">`).join('');
return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="Type to search model..."
oninput="handleModelInput('${uid}',this.value,${onChangeFn})"
onchange="handleModelInput('${uid}',this.value,${onChangeFn})"
style="width:100%;padding:8px;border:1px solid #0f3460;border-radius:3px;background:#1a1a2e;color:#e0e0e0;font-size:14px;">
<datalist id="${uid}-dl">${dlOpts}</datalist>
</div>`;
}
}
function handleModelInput(uid, value, callback) {
const allIds = availableModels.map(m => m.id).concat(availableRotations).concat(['internal']);
if (allIds.includes(value) || value === '') callback(value);
}
function renderAutoselectList() {
const container = document.getElementById('autoselect-list');
container.innerHTML = '';
......@@ -69,17 +98,17 @@ function renderAutoselectList() {
const isExpanded = expandedAutoselects.has(key);
const modelCount = autoselect.available_models ? autoselect.available_models.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
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 class="autoselect-header" onclick="toggleAutoselect('${safeKey}')" 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>
<strong style="font-size: 16px;">${escHtmlAttr(autoselect.model_name || key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span>
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
<div id="autoselect-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
<div id="autoselect-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div>
`;
......@@ -103,6 +132,7 @@ function toggleAutoselect(key) {
function renderAutoselectDetails(autoselectKey) {
const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey];
const safeAKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
// Default to "internal" if selection_model is not set
const selectionValue = autoselect.selection_model || 'internal';
......@@ -251,7 +281,12 @@ function renderAutoselectDetails(autoselectKey) {
<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>
<button type="button" class="btn btn-secondary" onclick="addAutoselectModel('${safeAKey}')" style="margin-top: 10px;">Add Model</button>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #0f3460; display: flex; gap: 10px; align-items: center;">
<button type="button" class="btn" onclick="saveAutoselectItem('${safeAKey}')" style="background: #22c55e;">Save This Autoselect</button>
<span id="asel-save-status-${autoselectKey}" style="font-size: 13px; color: #a0a0a0;"></span>
</div>
`;
renderAutoselectModels(autoselectKey);
......@@ -270,23 +305,20 @@ function renderAutoselectModels(autoselectKey) {
autoselect.available_models.forEach((model, index) => {
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('');
const safeKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const allOptions = availableRotations.map(r => ({id: r, name: r})).concat(availableModels);
const selUid = `asel-mod-${autoselectKey}-${index}`;
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>
<button type="button" class="btn btn-secondary" onclick="removeAutoselectModel('${safeKey}', ${index})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">Remove</button>
</div>
<div class="form-group">
<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>
${buildModelSelectHtml(selUid, model.model_id || '', allOptions,
`function(v){updateAutoselectModel('${safeKey}',${index},'model_id',v)}`)}
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small>
</div>
......@@ -315,31 +347,62 @@ function renderAutoselectModels(autoselectKey) {
});
}
async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) {
return;
async function apiCall(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
async function saveAutoselectItem(key) {
const statusEl = document.getElementById(`asel-save-status-${key}`);
if (statusEl) statusEl.textContent = 'Saving…';
try {
const result = await apiCall('POST', '/dashboard/api/autoselect', {
autoselect_id: key,
config: autoselectConfig[key]
});
if (result.success) {
if (statusEl) { statusEl.textContent = 'Saved!'; statusEl.style.color = '#4ade80'; setTimeout(() => { statusEl.textContent = ''; }, 3000); }
} else {
if (statusEl) { statusEl.textContent = 'Error: ' + (result.error || 'Unknown'); statusEl.style.color = '#f87171'; }
}
} catch (e) {
if (statusEl) { statusEl.textContent = 'Error: ' + e.message; statusEl.style.color = '#f87171'; }
}
}
async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) return;
if (autoselectConfig[key]) {
showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
autoselectConfig[key] = {
model_name: key,
description: '',
selection_model: '',
fallback: '',
available_models: []
};
const newCfg = { model_name: key, description: '', selection_model: '', fallback: '', available_models: [] };
autoselectConfig[key] = newCfg;
try {
const result = await apiCall('POST', '/dashboard/api/autoselect', { autoselect_id: key, config: newCfg });
if (!result.success) {
showAlert('Error creating autoselect: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete autoselectConfig[key];
return;
}
} catch (e) {
showAlert('Error creating autoselect: ' + e.message, 'Error', '❌', 'danger');
delete autoselectConfig[key];
return;
}
expandedAutoselects.add(key);
renderAutoselectList();
}
async function removeAutoselect(key) {
if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) {
try {
const result = await apiCall('DELETE', '/dashboard/api/autoselect/' + encodeURIComponent(key));
if (!result.success) { showAlert('Error: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger'); return; }
} catch (e) { showAlert('Error: ' + e.message, 'Error', '❌', 'danger'); return; }
delete autoselectConfig[key];
expandedAutoselects.delete(key);
renderAutoselectList();
......
......@@ -79,11 +79,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div id="providers-list" style="margin-bottom: 20px;">
<div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;">
<input type="text" id="providers-search" placeholder="Search providers by key or name..."
oninput="providerSearchFilter = this.value; currentProviderPage = 0; renderProvidersList();"
style="flex:1;padding:8px;border:1px solid #0f3460;border-radius:3px;background:#1a1a2e;color:#e0e0e0;font-size:14px;">
<span id="providers-count" style="color:#a0a0a0;font-size:13px;white-space:nowrap;"></span>
</div>
<div id="providers-list" style="margin-bottom: 10px;">
<!-- Provider list will be rendered here -->
</div>
<button type="button" class="btn" id="add-provider-btn" onclick="showAddProviderForm()" style="margin-top: 20px;">Add Provider</button>
<div id="providers-pagination" style="margin-bottom: 20px;"></div>
<button type="button" class="btn" id="add-provider-btn" onclick="showAddProviderForm()" style="margin-top: 10px;">Add Provider</button>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button type="button" class="btn" onclick="saveProviders()">Save Configuration</button>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
......@@ -135,9 +144,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block extra_js %}
<script>
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
async function apiCall(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
const CLAUDE_CLI_MODE = {{ 'true' if claude_cli_mode else 'false' }};
let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }};
let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = '';
// Chunk size: 512KB chunks for maximum compatibility with restrictive proxies
const CHUNK_SIZE = 512 * 1024;
......@@ -275,37 +298,47 @@ async function uploadCodexFile(providerKey, file) {
}
function renderProvidersList() {
console.log('=== renderProvidersList START ===');
const container = document.getElementById('providers-list');
console.log('Container element:', container);
if (!container) return;
if (!container) {
console.error('ERROR: providers-list container not found!');
return;
}
const allKeys = Object.keys(providersData);
const filteredKeys = providerSearchFilter
? allKeys.filter(k => k.toLowerCase().includes(providerSearchFilter.toLowerCase()) ||
(providersData[k].name || '').toLowerCase().includes(providerSearchFilter.toLowerCase()))
: allKeys;
container.innerHTML = '';
console.log('Cleared container, processing', Object.keys(providersData).length, 'providers');
const total = filteredKeys.length;
const totalPages = Math.max(1, Math.ceil(total / PROVIDERS_PAGE_SIZE));
if (currentProviderPage >= totalPages) currentProviderPage = totalPages - 1;
const pageKeys = filteredKeys.slice(currentProviderPage * PROVIDERS_PAGE_SIZE, (currentProviderPage + 1) * PROVIDERS_PAGE_SIZE);
Object.entries(providersData).forEach(([key, provider]) => {
console.log('Processing provider:', key);
const countEl = document.getElementById('providers-count');
if (countEl) countEl.textContent = `${total} provider${total !== 1 ? 's' : ''}`;
container.innerHTML = '';
if (pageKeys.length === 0) {
container.innerHTML = '<p style="color:#a0a0a0;">No providers found.</p>';
} else {
pageKeys.forEach(key => {
const provider = providersData[key];
const providerItem = document.createElement('div');
providerItem.className = 'provider-item';
providerItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;';
const isExpanded = expandedProviders.has(key);
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
providerItem.innerHTML = `
<div class="provider-header" onclick="toggleProvider('${key}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<div class="provider-header" onclick="toggleProvider('${safeKey}')" 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;">(${provider.name || key})</span>
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${escHtmlAttr(provider.name || key)})</span>
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
<div id="provider-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
<div id="provider-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div>
`;
......@@ -315,6 +348,35 @@ function renderProvidersList() {
renderProviderDetails(key);
}
});
}
// Pagination controls
const paginationEl = document.getElementById('providers-pagination');
if (paginationEl) {
if (totalPages <= 1) {
paginationEl.innerHTML = '';
} else {
let btns = '';
btns += `<button class="btn btn-secondary" onclick="goToProviderPage(${currentProviderPage - 1})" ${currentProviderPage === 0 ? 'disabled' : ''} style="padding:5px 12px;">← Prev</button>`;
const start = Math.max(0, currentProviderPage - 2);
const end = Math.min(totalPages - 1, currentProviderPage + 2);
for (let p = start; p <= end; p++) {
btns += `<button class="btn ${p === currentProviderPage ? '' : 'btn-secondary'}" onclick="goToProviderPage(${p})" style="padding:5px 10px;min-width:36px;">${p + 1}</button>`;
}
btns += `<button class="btn btn-secondary" onclick="goToProviderPage(${currentProviderPage + 1})" ${currentProviderPage >= totalPages - 1 ? 'disabled' : ''} style="padding:5px 12px;">Next →</button>`;
paginationEl.innerHTML = `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
${btns}
<span style="color:#a0a0a0;font-size:13px;margin-left:8px;">Page ${currentProviderPage + 1} of ${totalPages}</span>
</div>`;
}
}
}
function goToProviderPage(page) {
currentProviderPage = page;
expandedProviders.clear();
renderProvidersList();
window.scrollTo(0, 0);
}
function toggleProvider(key) {
......@@ -322,6 +384,14 @@ function toggleProvider(key) {
expandedProviders.delete(key);
} else {
expandedProviders.add(key);
// Ensure the key's page is visible
const allKeys = Object.keys(providersData);
const filteredKeys = providerSearchFilter
? allKeys.filter(k => k.toLowerCase().includes(providerSearchFilter.toLowerCase()) ||
(providersData[k].name || '').toLowerCase().includes(providerSearchFilter.toLowerCase()))
: allKeys;
const idx = filteredKeys.indexOf(key);
if (idx >= 0) currentProviderPage = Math.floor(idx / PROVIDERS_PAGE_SIZE);
}
renderProvidersList();
}
......@@ -329,6 +399,7 @@ function toggleProvider(key) {
function renderProviderDetails(key) {
const container = document.getElementById(`provider-details-${key}`);
const provider = providersData[key];
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude';
const isKiloProvider = provider.type === 'kilocode';
......@@ -933,13 +1004,18 @@ function renderProviderDetails(key) {
</div>
<div id="models-${key}"></div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<div style="display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap;">
<button type="button" class="btn" onclick="getModelsFromProvider('${key}')" style="background: #4a9eff;">
🔄 Get Models from Provider
</button>
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')">Add Model Manually</button>
</div>
<div id="get-models-status-${key}" style="margin-top: 10px;"></div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #0f3460; display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<button type="button" class="btn" onclick="saveProvider('${safeKey}')" style="background: #22c55e;">Save This Provider</button>
<span id="save-status-${key}" style="font-size: 13px; color: #a0a0a0;"></span>
</div>
`;
renderModels(key);
......@@ -1687,8 +1763,36 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo
}
async function saveProvider(key) {
const statusEl = document.getElementById(`save-status-${key}`);
if (statusEl) statusEl.textContent = 'Saving…';
try {
const result = await apiCall('POST', '/dashboard/api/provider', {
provider_id: key,
config: providersData[key]
});
if (result.success) {
if (statusEl) { statusEl.textContent = 'Saved!'; statusEl.style.color = '#4ade80'; setTimeout(() => { statusEl.textContent = ''; }, 3000); }
} else {
if (statusEl) { statusEl.textContent = 'Error: ' + (result.error || 'Unknown'); statusEl.style.color = '#f87171'; }
}
} catch (e) {
if (statusEl) { statusEl.textContent = 'Error: ' + e.message; statusEl.style.color = '#f87171'; }
}
}
async function removeProvider(key) {
if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) {
try {
const result = await apiCall('DELETE', '/dashboard/api/provider/' + encodeURIComponent(key));
if (!result.success) {
showAlert('Error removing provider: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
return;
}
} catch (e) {
showAlert('Error removing provider: ' + e.message, 'Error', '❌', 'danger');
return;
}
delete providersData[key];
expandedProviders.delete(key);
renderProvidersList();
......@@ -1834,9 +1938,24 @@ async function confirmAddProvider() {
};
}
// Add to expanded providers so it automatically expands
expandedProviders.add(key);
// Immediately persist the new provider
try {
const result = await apiCall('POST', '/dashboard/api/provider', {
provider_id: key,
config: providersData[key]
});
if (!result.success) {
showAlert('Error creating provider: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete providersData[key];
return;
}
} catch (e) {
showAlert('Error creating provider: ' + e.message, 'Error', '❌', 'danger');
delete providersData[key];
return;
}
expandedProviders.add(key);
cancelAddProvider();
renderProvidersList();
}
......@@ -1927,12 +2046,7 @@ async function saveProviders() {
}
}
// Initialize providers list immediately (DOM is already loaded since script is at end of body)
console.log('Initializing providers list...');
console.log('providersData:', Object.keys(providersData));
console.log('Number of providers:', Object.keys(providersData).length);
renderProvidersList();
console.log('renderProvidersList() completed');
</script>
{% endblock %}
......@@ -47,6 +47,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
<script>
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const rotationsData = {
config: {{ rotations_json | safe }},
providers: {{ available_providers | safe }},
......@@ -57,6 +61,61 @@ let rotationsConfig = rotationsData.config;
let availableProviders = rotationsData.providers;
let expandedRotations = new Set();
// Model name autocomplete: cache per provider
const providerModelsCache = {};
const modelSearchTimers = {};
async function fetchProviderModels(providerId) {
if (!providerId) return [];
if (providerModelsCache[providerId]) return providerModelsCache[providerId];
try {
const resp = await fetch(`/dashboard/providers/${encodeURIComponent(providerId)}/configured-models`);
if (!resp.ok) return [];
const data = await resp.json();
providerModelsCache[providerId] = data.models || [];
return providerModelsCache[providerId];
} catch { return []; }
}
function searchModelsDebounced(datalistId, query, providerId, timerKey) {
clearTimeout(modelSearchTimers[timerKey]);
if (query.length < 3 || !providerId) return;
modelSearchTimers[timerKey] = setTimeout(async () => {
let models = await fetchProviderModels(providerId);
const q = query.toLowerCase();
models = models.filter(m => m.toLowerCase().includes(q)).slice(0, 30);
const dl = document.getElementById(datalistId);
if (dl) dl.innerHTML = models.map(m => `<option value="${escHtmlAttr(m)}">`).join('');
}, 350);
}
// Provider select widget: <select> for 25, datalist input for >25
function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
if (availableProviders.length <= 25) {
const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join('');
return `<select id="${uid}" onchange="${onChangeFn}(this.value)" required>
<option value="">Select provider...</option>${opts}</select>`;
} else {
const dlOpts = availableProviders.map(p => `<option value="${escHtmlAttr(p)}">`).join('');
return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="Type to search provider..."
oninput="handleProviderInput('${uid}', this.value, ${onChangeFn})"
onchange="handleProviderInput('${uid}', this.value, ${onChangeFn})"
style="width:100%;padding:8px;border:1px solid #0f3460;border-radius:3px;background:#1a1a2e;color:#e0e0e0;font-size:14px;">
<datalist id="${uid}-dl">${dlOpts}</datalist>
</div>`;
}
}
function handleProviderInput(uid, value, callback) {
if (availableProviders.includes(value) || value === '') {
callback(value);
}
}
document.getElementById('global-notify').checked = rotationsConfig.notifyerrors || false;
function renderRotationsList() {
......@@ -70,18 +129,18 @@ function renderRotationsList() {
const isExpanded = expandedRotations.has(key);
const providerCount = rotation.providers ? rotation.providers.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
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 class="rotation-header" onclick="toggleRotation('${safeKey}')" 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>
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span>
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</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 id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div>
`;
......@@ -105,6 +164,7 @@ function toggleRotation(key) {
function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey];
const safeRKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
container.innerHTML = `
<div class="form-group">
......@@ -150,7 +210,12 @@ function renderRotationDetails(rotationKey) {
<h4 style="margin-top: 20px; margin-bottom: 10px;">Providers</h4>
<div id="providers-${rotationKey}"></div>
<button type="button" class="btn btn-secondary" onclick="addRotationProvider('${rotationKey}')" style="margin-top: 10px;">Add Provider</button>
<button type="button" class="btn btn-secondary" onclick="addRotationProvider('${safeRKey}')" style="margin-top: 10px;">Add Provider</button>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #0f3460; display: flex; gap: 10px; align-items: center;">
<button type="button" class="btn" onclick="saveRotation('${safeRKey}')" style="background: #22c55e;">Save This Rotation</button>
<span id="rot-save-status-${rotationKey}" style="font-size: 13px; color: #a0a0a0;"></span>
</div>
`;
renderRotationProviders(rotationKey);
......@@ -169,33 +234,29 @@ function renderRotationProviders(rotationKey) {
rotation.providers.forEach((provider, providerIndex) => {
const providerDiv = document.createElement('div');
providerDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
const providerOptions = availableProviders.map(p =>
`<option value="${p}" ${provider.provider_id === p ? 'selected' : ''}>${p}</option>`
).join('');
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const provSelUid = `prov-sel-${rotationKey}-${providerIndex}`;
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>
<button type="button" class="btn btn-secondary" onclick="removeRotationProvider('${safeKey}', ${providerIndex})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">Remove</button>
</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>
${buildProviderSelectHtml(provSelUid, provider.provider_id || '',
`function(v){updateRotationProvider('${safeKey}',${providerIndex},'provider_id',v)}`)}
</div>
<div class="form-group">
<label>Weight (optional, for provider-level weight)</label>
<input type="number" value="${provider.weight || ''}" onchange="updateRotationProvider('${rotationKey}', ${providerIndex}, 'weight', this.value ? parseInt(this.value) : null)" placeholder="Optional">
<input type="number" value="${provider.weight || ''}" onchange="updateRotationProvider('${safeKey}', ${providerIndex}, 'weight', this.value ? parseInt(this.value) : null)" placeholder="Optional">
</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>
<button type="button" class="btn btn-secondary" onclick="addRotationModel('${safeKey}', ${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>
`;
......@@ -217,16 +278,25 @@ function renderRotationModels(rotationKey, providerIndex) {
provider.models.forEach((model, modelIndex) => {
const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 10px; margin-bottom: 8px; border-radius: 3px; background: #16213e;';
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const dlId = `mdl-dl-${rotationKey}-${providerIndex}-${modelIndex}`;
const timerKey = `${rotationKey}-${providerIndex}-${modelIndex}`;
const providerId = provider.provider_id || '';
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>
<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;">Remove</button>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<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;">
<input type="text" value="${escHtmlAttr(model.name)}" list="${dlId}"
placeholder="Type 3+ chars to search..."
oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value);searchModelsDebounced('${dlId}',this.value,'${escHtmlAttr(providerId)}','${timerKey}')"
onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
required style="font-size: 12px; padding: 5px;">
<datalist id="${dlId}"></datalist>
</div>
<div class="form-group" style="margin-bottom: 8px;">
......@@ -268,6 +338,31 @@ function updateGlobalNotify(value) {
rotationsConfig.notifyerrors = value;
}
async function apiCall(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
async function saveRotation(key) {
const statusEl = document.getElementById(`rot-save-status-${key}`);
if (statusEl) statusEl.textContent = 'Saving…';
try {
const result = await apiCall('POST', '/dashboard/api/rotation', {
rotation_id: key,
config: rotationsConfig.rotations[key]
});
if (result.success) {
if (statusEl) { statusEl.textContent = 'Saved!'; statusEl.style.color = '#4ade80'; setTimeout(() => { statusEl.textContent = ''; }, 3000); }
} else {
if (statusEl) { statusEl.textContent = 'Error: ' + (result.error || 'Unknown'); statusEl.style.color = '#f87171'; }
}
} catch (e) {
if (statusEl) { statusEl.textContent = 'Error: ' + e.message; statusEl.style.color = '#f87171'; }
}
}
async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return;
......@@ -275,24 +370,31 @@ async function addRotation() {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
if (!rotationsConfig.rotations) {
rotationsConfig.rotations = {};
if (!rotationsConfig.rotations) rotationsConfig.rotations = {};
const newCfg = { model_name: key, notifyerrors: false, capabilities: [], providers: [] };
rotationsConfig.rotations[key] = newCfg;
try {
const result = await apiCall('POST', '/dashboard/api/rotation', { rotation_id: key, config: newCfg });
if (!result.success) {
showAlert('Error creating rotation: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete rotationsConfig.rotations[key];
return;
}
} catch (e) {
showAlert('Error creating rotation: ' + e.message, 'Error', '❌', 'danger');
delete rotationsConfig.rotations[key];
return;
}
rotationsConfig.rotations[key] = {
model_name: key,
notifyerrors: false,
capabilities: [],
providers: []
};
expandedRotations.add(key);
renderRotationsList();
}
async function removeRotation(key) {
if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) {
try {
const result = await apiCall('DELETE', '/dashboard/api/rotation/' + encodeURIComponent(key));
if (!result.success) { showAlert('Error: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger'); return; }
} catch (e) { showAlert('Error: ' + e.message, 'Error', '❌', 'danger'); return; }
delete rotationsConfig.rotations[key];
expandedRotations.delete(key);
renderRotationsList();
......
......@@ -43,6 +43,43 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const escHtmlAttr = escHtml;
async function apiCall(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
// Build a searchable select for model lists (datalist when >25 options)
function buildModelSelectHtml(uid, currentValue, allModels, onChangeFn) {
if (allModels.length <= 25) {
const opts = allModels.map(m =>
`<option value="${escHtml(m.id)}" ${currentValue === m.id ? 'selected' : ''}>${escHtml(m.name)}</option>`
).join('');
return `<select id="${uid}" onchange="${onChangeFn}(this.value)" required>
<option value="">Select model...</option>${opts}</select>`;
} else {
const currentLabel = (allModels.find(m => m.id === currentValue) || {}).name || currentValue;
const dlOpts = allModels.map(m => `<option value="${escHtml(m.id)}" label="${escHtml(m.name)}">`).join('');
return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtml(currentValue)}" list="${uid}-dl"
placeholder="Type to search model..."
oninput="handleModelInput('${uid}',this.value,${onChangeFn})"
onchange="handleModelInput('${uid}',this.value,${onChangeFn})"
style="width:100%;padding:8px;border:1px solid #0f3460;border-radius:3px;background:#1a1a2e;color:#e0e0e0;font-size:14px;">
<datalist id="${uid}-dl">${dlOpts}</datalist>
</div>`;
}
}
function handleModelInput(uid, value, callback) {
const allIds = availableModels.map(m => m.id).concat(availableRotations);
if (allIds.includes(value) || value === 'internal' || value === '') {
callback(value);
}
}
const autoselectData = {
config: {{ autoselect_json | safe }},
......@@ -72,17 +109,18 @@ function renderAutoselectList() {
const isExpanded = expandedAutoselects.has(key);
const modelCount = autoselect.available_models ? autoselect.available_models.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
autoselectItem.innerHTML = `
<div class="autoselect-header" onclick="toggleAutoselect('${escHtml(key)}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<div class="autoselect-header" onclick="toggleAutoselect('${safeKey}')" 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;">${escHtml(autoselect.model_name || key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span>
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${escHtml(key)}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
<div id="autoselect-details-${escHtml(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<div id="autoselect-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
</div>
`;
......@@ -107,6 +145,7 @@ function toggleAutoselect(key) {
function renderAutoselectDetails(autoselectKey) {
const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey];
const safeAKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
// Default to "internal" if selection_model is not set
const selectionValue = autoselect.selection_model || 'internal';
......@@ -255,7 +294,12 @@ function renderAutoselectDetails(autoselectKey) {
<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>
<button type="button" class="btn btn-secondary" onclick="addAutoselectModel('${safeAKey}')" style="margin-top: 10px;">Add Model</button>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #0f3460; display: flex; gap: 10px; align-items: center;">
<button type="button" class="btn" onclick="saveAutoselectItem('${safeAKey}')" style="background: #22c55e;">Save This Autoselect</button>
<span id="asel-save-status-${autoselectKey}" style="font-size: 13px; color: #a0a0a0;"></span>
</div>
`;
renderAutoselectModels(autoselectKey);
......@@ -274,23 +318,20 @@ function renderAutoselectModels(autoselectKey) {
autoselect.available_models.forEach((model, index) => {
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('');
const safeKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const allOptions = availableRotations.map(r => ({id: r, name: r})).concat(availableModels);
const selUid = `asel-mod-${autoselectKey}-${index}`;
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>
<button type="button" class="btn btn-secondary" onclick="removeAutoselectModel('${safeKey}', ${index})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">Remove</button>
</div>
<div class="form-group">
<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>
${buildModelSelectHtml(selUid, model.model_id || '', allOptions,
`function(v){updateAutoselectModel('${safeKey}',${index},'model_id',v)}`)}
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small>
</div>
......@@ -319,31 +360,55 @@ function renderAutoselectModels(autoselectKey) {
});
}
async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) {
return;
async function saveAutoselectItem(key) {
const statusEl = document.getElementById(`asel-save-status-${key}`);
if (statusEl) statusEl.textContent = 'Saving…';
try {
const result = await apiCall('POST', '/dashboard/api/autoselect', {
autoselect_id: key,
config: autoselectConfig[key]
});
if (result.success) {
if (statusEl) { statusEl.textContent = 'Saved!'; statusEl.style.color = '#4ade80'; setTimeout(() => { statusEl.textContent = ''; }, 3000); }
} else {
if (statusEl) { statusEl.textContent = 'Error: ' + (result.error || 'Unknown'); statusEl.style.color = '#f87171'; }
}
} catch (e) {
if (statusEl) { statusEl.textContent = 'Error: ' + e.message; statusEl.style.color = '#f87171'; }
}
}
async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) return;
if (autoselectConfig[key]) {
showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
autoselectConfig[key] = {
model_name: key,
description: '',
selection_model: '',
fallback: '',
available_models: []
};
const newCfg = { model_name: key, description: '', selection_model: '', fallback: '', available_models: [] };
autoselectConfig[key] = newCfg;
try {
const result = await apiCall('POST', '/dashboard/api/autoselect', { autoselect_id: key, config: newCfg });
if (!result.success) {
showAlert('Error creating autoselect: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete autoselectConfig[key];
return;
}
} catch (e) {
showAlert('Error creating autoselect: ' + e.message, 'Error', '❌', 'danger');
delete autoselectConfig[key];
return;
}
expandedAutoselects.add(key);
renderAutoselectList();
}
async function removeAutoselect(key) {
if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) {
try {
const result = await apiCall('DELETE', '/dashboard/api/autoselect/' + encodeURIComponent(key));
if (!result.success) { showAlert('Error: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger'); return; }
} catch (e) { showAlert('Error: ' + e.message, 'Error', '❌', 'danger'); return; }
delete autoselectConfig[key];
expandedAutoselects.delete(key);
renderAutoselectList();
......
......@@ -79,11 +79,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div id="providers-list" style="margin-bottom: 20px;">
<div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;">
<input type="text" id="providers-search" placeholder="Search providers by key or name..."
oninput="providerSearchFilter = this.value; currentProviderPage = 0; renderProvidersList();"
style="flex:1;padding:8px;border:1px solid #0f3460;border-radius:3px;background:#1a1a2e;color:#e0e0e0;font-size:14px;">
<span id="providers-count" style="color:#a0a0a0;font-size:13px;white-space:nowrap;"></span>
</div>
<div id="providers-list" style="margin-bottom: 10px;">
<!-- Provider list will be rendered here -->
</div>
<button type="button" class="btn" id="add-provider-btn" onclick="showAddProviderForm()" style="margin-top: 20px;">Add Provider</button>
<div id="providers-pagination" style="margin-bottom: 20px;"></div>
<button type="button" class="btn" id="add-provider-btn" onclick="showAddProviderForm()" style="margin-top: 10px;">Add Provider</button>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button type="button" class="btn" onclick="saveProviders()">Save Configuration</button>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
......@@ -252,8 +261,22 @@ function showToast(message, type) {
}, 4000);
}
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
async function apiCall(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
let providersData = {};
let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = '';
let rawProviders = {{ user_providers_json | replace("</script>", "<\\/script>") | safe }};
// Convert user providers format to the format expected by the UI
......@@ -397,37 +420,47 @@ async function uploadCodexFile(providerKey, file) {
}
function renderProvidersList() {
console.log('=== renderProvidersList START ===');
const container = document.getElementById('providers-list');
console.log('Container element:', container);
if (!container) return;
if (!container) {
console.error('ERROR: providers-list container not found!');
return;
}
const allKeys = Object.keys(providersData);
const filteredKeys = providerSearchFilter
? allKeys.filter(k => k.toLowerCase().includes(providerSearchFilter.toLowerCase()) ||
(providersData[k].name || '').toLowerCase().includes(providerSearchFilter.toLowerCase()))
: allKeys;
container.innerHTML = '';
console.log('Cleared container, processing', Object.keys(providersData).length, 'providers');
const total = filteredKeys.length;
const totalPages = Math.max(1, Math.ceil(total / PROVIDERS_PAGE_SIZE));
if (currentProviderPage >= totalPages) currentProviderPage = totalPages - 1;
const pageKeys = filteredKeys.slice(currentProviderPage * PROVIDERS_PAGE_SIZE, (currentProviderPage + 1) * PROVIDERS_PAGE_SIZE);
Object.entries(providersData).forEach(([key, provider]) => {
console.log('Processing provider:', key);
const countEl = document.getElementById('providers-count');
if (countEl) countEl.textContent = `${total} provider${total !== 1 ? 's' : ''}`;
container.innerHTML = '';
if (pageKeys.length === 0) {
container.innerHTML = '<p style="color:#a0a0a0;">No providers found.</p>';
} else {
pageKeys.forEach(key => {
const provider = providersData[key];
const providerItem = document.createElement('div');
providerItem.className = 'provider-item';
providerItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;';
const isExpanded = expandedProviders.has(key);
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
providerItem.innerHTML = `
<div class="provider-header" onclick="toggleProvider('${key}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<div class="provider-header" onclick="toggleProvider('${safeKey}')" 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;">(${provider.name || key})</span>
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${escHtmlAttr(provider.name || key)})</span>
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
<div id="provider-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
<div id="provider-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div>
`;
......@@ -437,6 +470,34 @@ function renderProvidersList() {
renderProviderDetails(key);
}
});
}
const paginationEl = document.getElementById('providers-pagination');
if (paginationEl) {
if (totalPages <= 1) {
paginationEl.innerHTML = '';
} else {
let btns = '';
btns += `<button class="btn btn-secondary" onclick="goToProviderPage(${currentProviderPage - 1})" ${currentProviderPage === 0 ? 'disabled' : ''} style="padding:5px 12px;">← Prev</button>`;
const start = Math.max(0, currentProviderPage - 2);
const end = Math.min(totalPages - 1, currentProviderPage + 2);
for (let p = start; p <= end; p++) {
btns += `<button class="btn ${p === currentProviderPage ? '' : 'btn-secondary'}" onclick="goToProviderPage(${p})" style="padding:5px 10px;min-width:36px;">${p + 1}</button>`;
}
btns += `<button class="btn btn-secondary" onclick="goToProviderPage(${currentProviderPage + 1})" ${currentProviderPage >= totalPages - 1 ? 'disabled' : ''} style="padding:5px 12px;">Next →</button>`;
paginationEl.innerHTML = `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
${btns}
<span style="color:#a0a0a0;font-size:13px;margin-left:8px;">Page ${currentProviderPage + 1} of ${totalPages}</span>
</div>`;
}
}
}
function goToProviderPage(page) {
currentProviderPage = page;
expandedProviders.clear();
renderProvidersList();
window.scrollTo(0, 0);
}
function toggleProvider(key) {
......@@ -444,6 +505,13 @@ function toggleProvider(key) {
expandedProviders.delete(key);
} else {
expandedProviders.add(key);
const allKeys = Object.keys(providersData);
const filteredKeys = providerSearchFilter
? allKeys.filter(k => k.toLowerCase().includes(providerSearchFilter.toLowerCase()) ||
(providersData[k].name || '').toLowerCase().includes(providerSearchFilter.toLowerCase()))
: allKeys;
const idx = filteredKeys.indexOf(key);
if (idx >= 0) currentProviderPage = Math.floor(idx / PROVIDERS_PAGE_SIZE);
}
renderProvidersList();
}
......@@ -451,6 +519,7 @@ function toggleProvider(key) {
function renderProviderDetails(key) {
const container = document.getElementById(`provider-details-${key}`);
const provider = providersData[key];
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude';
const isKiloProvider = provider.type === 'kilocode';
......@@ -1020,6 +1089,11 @@ function renderProviderDetails(key) {
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')">Add Model Manually</button>
</div>
<div id="get-models-status-${key}" style="margin-top: 10px;"></div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #0f3460; display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<button type="button" class="btn" onclick="saveProvider('${safeKey}')" style="background: #22c55e;">Save This Provider</button>
<span id="save-status-${key}" style="font-size: 13px; color: #a0a0a0;"></span>
</div>
`;
renderModels(key);
......@@ -1776,8 +1850,36 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo
}
async function saveProvider(key) {
const statusEl = document.getElementById(`save-status-${key}`);
if (statusEl) statusEl.textContent = 'Saving…';
try {
const result = await apiCall('POST', '/dashboard/api/provider', {
provider_id: key,
config: providersData[key]
});
if (result.success) {
if (statusEl) { statusEl.textContent = 'Saved!'; statusEl.style.color = '#4ade80'; setTimeout(() => { statusEl.textContent = ''; }, 3000); }
} else {
if (statusEl) { statusEl.textContent = 'Error: ' + (result.error || 'Unknown'); statusEl.style.color = '#f87171'; }
}
} catch (e) {
if (statusEl) { statusEl.textContent = 'Error: ' + e.message; statusEl.style.color = '#f87171'; }
}
}
async function removeProvider(key) {
if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) {
try {
const result = await apiCall('DELETE', '/dashboard/api/provider/' + encodeURIComponent(key));
if (!result.success) {
showAlert('Error removing provider: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
return;
}
} catch (e) {
showAlert('Error removing provider: ' + e.message, 'Error', '❌', 'danger');
return;
}
delete providersData[key];
expandedProviders.delete(key);
renderProvidersList();
......@@ -1916,9 +2018,23 @@ async function confirmAddProvider() {
};
}
// Add to expanded providers so it automatically expands
expandedProviders.add(key);
try {
const result = await apiCall('POST', '/dashboard/api/provider', {
provider_id: key,
config: providersData[key]
});
if (!result.success) {
showAlert('Error creating provider: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete providersData[key];
return;
}
} catch (e) {
showAlert('Error creating provider: ' + e.message, 'Error', '❌', 'danger');
delete providersData[key];
return;
}
expandedProviders.add(key);
cancelAddProvider();
renderProvidersList();
}
......@@ -2009,14 +2125,9 @@ async function saveProviders() {
}
}
// Initialize providers list immediately (DOM is already loaded since script is at end of body)
async function initProviders() {
console.log('Initializing providers list...');
await loadCacheSettings();
console.log('providersData:', Object.keys(providersData));
console.log('Number of providers:', Object.keys(providersData).length);
renderProvidersList();
console.log('renderProvidersList() completed');
}
initProviders();
......
......@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
<script>
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const rotationsData = {
config: {{ rotations_json | safe }},
providers: {{ available_providers | safe }},
......@@ -50,6 +54,59 @@ let rotationsConfig = rotationsData.config;
let availableProviders = rotationsData.providers;
let expandedRotations = new Set();
const providerModelsCache = {};
const modelSearchTimers = {};
async function fetchProviderModels(providerId) {
if (!providerId) return [];
if (providerModelsCache[providerId]) return providerModelsCache[providerId];
try {
const resp = await fetch(`/dashboard/providers/${encodeURIComponent(providerId)}/configured-models`);
if (!resp.ok) return [];
const data = await resp.json();
providerModelsCache[providerId] = data.models || [];
return providerModelsCache[providerId];
} catch { return []; }
}
function searchModelsDebounced(datalistId, query, providerId, timerKey) {
clearTimeout(modelSearchTimers[timerKey]);
if (query.length < 3 || !providerId) return;
modelSearchTimers[timerKey] = setTimeout(async () => {
let models = await fetchProviderModels(providerId);
const q = query.toLowerCase();
models = models.filter(m => m.toLowerCase().includes(q)).slice(0, 30);
const dl = document.getElementById(datalistId);
if (dl) dl.innerHTML = models.map(m => `<option value="${escHtmlAttr(m)}">`).join('');
}, 350);
}
function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
if (availableProviders.length <= 25) {
const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join('');
return `<select id="${uid}" onchange="${onChangeFn}(this.value)" required>
<option value="">Select provider...</option>${opts}</select>`;
} else {
const dlOpts = availableProviders.map(p => `<option value="${escHtmlAttr(p)}">`).join('');
return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="Type to search provider..."
oninput="handleProviderInput('${uid}',this.value,${onChangeFn})"
onchange="handleProviderInput('${uid}',this.value,${onChangeFn})"
style="width:100%;padding:8px;border:1px solid #0f3460;border-radius:3px;background:#1a1a2e;color:#e0e0e0;font-size:14px;">
<datalist id="${uid}-dl">${dlOpts}</datalist>
</div>`;
}
}
function handleProviderInput(uid, value, callback) {
if (availableProviders.includes(value) || value === '') {
callback(value);
}
}
function renderRotationsList() {
const container = document.getElementById('rotations-list');
container.innerHTML = '';
......@@ -61,18 +118,18 @@ function renderRotationsList() {
const isExpanded = expandedRotations.has(key);
const providerCount = rotation.providers ? rotation.providers.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
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 class="rotation-header" onclick="toggleRotation('${safeKey}')" 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>
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span>
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${key}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</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 id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div>
`;
......@@ -96,6 +153,7 @@ function toggleRotation(key) {
function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey];
const safeRKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
container.innerHTML = `
<div class="form-group">
......@@ -145,7 +203,12 @@ function renderRotationDetails(rotationKey) {
<h4 style="margin-top: 20px; margin-bottom: 10px;">Providers</h4>
<div id="providers-${rotationKey}"></div>
<button type="button" class="btn btn-secondary" onclick="addRotationProvider('${rotationKey}')" style="margin-top: 10px;">Add Provider</button>
<button type="button" class="btn btn-secondary" onclick="addRotationProvider('${safeRKey}')" style="margin-top: 10px;">Add Provider</button>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #0f3460; display: flex; gap: 10px; align-items: center;">
<button type="button" class="btn" onclick="saveRotation('${safeRKey}')" style="background: #22c55e;">Save This Rotation</button>
<span id="rot-save-status-${rotationKey}" style="font-size: 13px; color: #a0a0a0;"></span>
</div>
`;
renderRotationProviders(rotationKey);
......@@ -164,33 +227,29 @@ function renderRotationProviders(rotationKey) {
rotation.providers.forEach((provider, providerIndex) => {
const providerDiv = document.createElement('div');
providerDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
const providerOptions = availableProviders.map(p =>
`<option value="${p}" ${provider.provider_id === p ? 'selected' : ''}>${p}</option>`
).join('');
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const provSelUid = `prov-sel-${rotationKey}-${providerIndex}`;
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>
<button type="button" class="btn btn-secondary" onclick="removeRotationProvider('${safeKey}', ${providerIndex})" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">Remove</button>
</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>
${buildProviderSelectHtml(provSelUid, provider.provider_id || '',
`function(v){updateRotationProvider('${safeKey}',${providerIndex},'provider_id',v)}`)}
</div>
<div class="form-group">
<label>Weight (optional, for provider-level weight)</label>
<input type="number" value="${provider.weight || ''}" onchange="updateRotationProvider('${rotationKey}', ${providerIndex}, 'weight', this.value ? parseInt(this.value) : null)" placeholder="Optional">
<input type="number" value="${provider.weight || ''}" onchange="updateRotationProvider('${safeKey}', ${providerIndex}, 'weight', this.value ? parseInt(this.value) : null)" placeholder="Optional">
</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>
<button type="button" class="btn btn-secondary" onclick="addRotationModel('${safeKey}', ${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>
`;
......@@ -212,16 +271,25 @@ function renderRotationModels(rotationKey, providerIndex) {
provider.models.forEach((model, modelIndex) => {
const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 10px; margin-bottom: 8px; border-radius: 3px; background: #16213e;';
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const dlId = `mdl-dl-${rotationKey}-${providerIndex}-${modelIndex}`;
const timerKey = `${rotationKey}-${providerIndex}-${modelIndex}`;
const providerId = provider.provider_id || '';
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>
<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;">Remove</button>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<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;">
<input type="text" value="${escHtmlAttr(model.name)}" list="${dlId}"
placeholder="Type 3+ chars to search..."
oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value);searchModelsDebounced('${dlId}',this.value,'${escHtmlAttr(providerId)}','${timerKey}')"
onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
required style="font-size: 12px; padding: 5px;">
<datalist id="${dlId}"></datalist>
</div>
<div class="form-group" style="margin-bottom: 8px;">
......@@ -259,6 +327,31 @@ function renderRotationModels(rotationKey, providerIndex) {
});
}
async function apiCall(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
async function saveRotation(key) {
const statusEl = document.getElementById(`rot-save-status-${key}`);
if (statusEl) statusEl.textContent = 'Saving…';
try {
const result = await apiCall('POST', '/dashboard/api/rotation', {
rotation_id: key,
config: rotationsConfig.rotations[key]
});
if (result.success) {
if (statusEl) { statusEl.textContent = 'Saved!'; statusEl.style.color = '#4ade80'; setTimeout(() => { statusEl.textContent = ''; }, 3000); }
} else {
if (statusEl) { statusEl.textContent = 'Error: ' + (result.error || 'Unknown'); statusEl.style.color = '#f87171'; }
}
} catch (e) {
if (statusEl) { statusEl.textContent = 'Error: ' + e.message; statusEl.style.color = '#f87171'; }
}
}
async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return;
......@@ -266,24 +359,31 @@ async function addRotation() {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
if (!rotationsConfig.rotations) {
rotationsConfig.rotations = {};
if (!rotationsConfig.rotations) rotationsConfig.rotations = {};
const newCfg = { model_name: key, notifyerrors: false, capabilities: [], providers: [] };
rotationsConfig.rotations[key] = newCfg;
try {
const result = await apiCall('POST', '/dashboard/api/rotation', { rotation_id: key, config: newCfg });
if (!result.success) {
showAlert('Error creating rotation: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete rotationsConfig.rotations[key];
return;
}
} catch (e) {
showAlert('Error creating rotation: ' + e.message, 'Error', '❌', 'danger');
delete rotationsConfig.rotations[key];
return;
}
rotationsConfig.rotations[key] = {
model_name: key,
notifyerrors: false,
capabilities: [],
providers: []
};
expandedRotations.add(key);
renderRotationsList();
}
async function removeRotation(key) {
if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) {
try {
const result = await apiCall('DELETE', '/dashboard/api/rotation/' + encodeURIComponent(key));
if (!result.success) { showAlert('Error: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger'); return; }
} catch (e) { showAlert('Error: ' + e.message, 'Error', '❌', 'danger'); return; }
delete rotationsConfig.rotations[key];
expandedRotations.delete(key);
renderRotationsList();
......
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