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: ...@@ -2987,7 +2987,7 @@ class RotationHandler:
completion_tokens = 0 completion_tokens = 0
# Try to extract actual cost from provider response # 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) actual_cost = extract_cost_from_response(response, provider_id)
# Always record analytics # Always record analytics
...@@ -4485,7 +4485,7 @@ class AutoselectHandler: ...@@ -4485,7 +4485,7 @@ class AutoselectHandler:
completion_tokens = 0 completion_tokens = 0
# Try to extract actual cost from provider response # 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') actual_cost = extract_cost_from_response(response, 'autoselect')
# Always record analytics # Always record analytics
......
...@@ -62,6 +62,7 @@ from pathlib import Path ...@@ -62,6 +62,7 @@ from pathlib import Path
import json import json
import re import re
import markdown import markdown
import threading
from urllib.parse import urljoin, urlencode from urllib.parse import urljoin, urlencode
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
...@@ -369,6 +370,57 @@ def setup_logging(): ...@@ -369,6 +370,57 @@ def setup_logging():
# Configure logging # Configure logging
logger = setup_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): class ProxyHeadersMiddleware(BaseHTTPMiddleware):
""" """
Middleware to handle proxy headers and make the application proxy-aware. Middleware to handle proxy headers and make the application proxy-aware.
...@@ -4408,7 +4460,7 @@ async def dashboard_providers(request: Request): ...@@ -4408,7 +4460,7 @@ async def dashboard_providers(request: Request):
"__version__": __version__, "__version__": __version__,
"providers_json": json.dumps(providers_data), "providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode, "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: else:
...@@ -4599,6 +4651,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4599,6 +4651,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
save_path.parent.mkdir(parents=True, exist_ok=True) save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w') as f: with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2) json.dump(full_config, f, indent=2)
_reload_global_config()
else: else:
# Database user: save to database # Database user: save to database
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
...@@ -4620,7 +4673,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -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}") logger.info(f"Saved {len(providers_data)} provider(s) to database for user {current_user_id}")
if is_config_admin: if is_config_admin:
success_msg = "Configuration saved successfully! Restart server for changes to take effect." success_msg = "Configuration saved successfully!"
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="dashboard/providers.html", name="dashboard/providers.html",
...@@ -4787,6 +4840,49 @@ async def dashboard_providers_get_models(request: Request): ...@@ -4787,6 +4840,49 @@ async def dashboard_providers_get_models(request: Request):
"error": str(e) "error": str(e)
}, status_code=500) }, 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) @app.get("/dashboard/rotations", response_class=HTMLResponse)
async def dashboard_rotations(request: Request): async def dashboard_rotations(request: Request):
"""Edit rotations configuration""" """Edit rotations configuration"""
...@@ -4839,7 +4935,7 @@ async def dashboard_rotations(request: Request): ...@@ -4839,7 +4935,7 @@ async def dashboard_rotations(request: Request):
"session": request.session, "session": request.session,
"rotations_json": json.dumps(rotations_data), "rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers), "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: else:
...@@ -4888,6 +4984,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)): ...@@ -4888,6 +4984,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
config_path.parent.mkdir(parents=True, exist_ok=True) config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
json.dump(rotations_data, f, indent=2) json.dump(rotations_data, f, indent=2)
_reload_global_config()
else: else:
# Database user: save to database # Database user: save to database
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
...@@ -4921,7 +5018,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)): ...@@ -4921,7 +5018,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
"__version__": __version__, "__version__": __version__,
"rotations_json": json.dumps(rotations_data), "rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers), "available_providers": json.dumps(available_providers),
"success": "Configuration saved successfully! Restart server for changes to take effect." "success": "Configuration saved successfully!"
} }
) )
else: else:
...@@ -5072,7 +5169,7 @@ async def dashboard_autoselect(request: Request): ...@@ -5072,7 +5169,7 @@ async def dashboard_autoselect(request: Request):
"autoselect_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),
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None "success": "Configuration saved successfully!" if success else None
} }
) )
else: else:
...@@ -5144,6 +5241,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)): ...@@ -5144,6 +5241,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
config_path.parent.mkdir(parents=True, exist_ok=True) config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
json.dump(autoselect_data, f, indent=2) json.dump(autoselect_data, f, indent=2)
_reload_global_config()
else: else:
# Database user: save to database # Database user: save to database
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
...@@ -5207,7 +5305,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)): ...@@ -5207,7 +5305,7 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
"autoselect_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),
"success": "Configuration saved successfully! Restart server for changes to take effect." "success": "Configuration saved successfully!"
} }
) )
else: else:
...@@ -5367,6 +5465,233 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)): ...@@ -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) @app.get("/dashboard/prompts", response_class=HTMLResponse)
async def dashboard_prompts(request: Request): async def dashboard_prompts(request: Request):
"""Edit prompt templates""" """Edit prompt templates"""
......
...@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
<script> <script>
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const autoselectData = { const autoselectData = {
config: {{ autoselect_json | safe }}, config: {{ autoselect_json | safe }},
rotations: {{ available_rotations | safe }}, rotations: {{ available_rotations | safe }},
...@@ -52,6 +56,31 @@ let availableRotations = autoselectData.rotations; ...@@ -52,6 +56,31 @@ let availableRotations = autoselectData.rotations;
let availableModels = autoselectData.models; let availableModels = autoselectData.models;
let expandedAutoselects = new Set(); 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() { function renderAutoselectList() {
const container = document.getElementById('autoselect-list'); const container = document.getElementById('autoselect-list');
container.innerHTML = ''; container.innerHTML = '';
...@@ -69,17 +98,17 @@ function renderAutoselectList() { ...@@ -69,17 +98,17 @@ function renderAutoselectList() {
const isExpanded = expandedAutoselects.has(key); const isExpanded = expandedAutoselects.has(key);
const modelCount = autoselect.available_models ? autoselect.available_models.length : 0; const modelCount = autoselect.available_models ? autoselect.available_models.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
autoselectItem.innerHTML = ` 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;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span> <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> <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> <button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div> </div>
<div id="autoselect-details-${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> </div>
`; `;
...@@ -103,6 +132,7 @@ function toggleAutoselect(key) { ...@@ -103,6 +132,7 @@ function toggleAutoselect(key) {
function renderAutoselectDetails(autoselectKey) { function renderAutoselectDetails(autoselectKey) {
const container = document.getElementById(`autoselect-details-${autoselectKey}`); const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey]; const autoselect = autoselectConfig[autoselectKey];
const safeAKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
// Default to "internal" if selection_model is not set // Default to "internal" if selection_model is not set
const selectionValue = autoselect.selection_model || 'internal'; const selectionValue = autoselect.selection_model || 'internal';
...@@ -251,7 +281,12 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -251,7 +281,12 @@ function renderAutoselectDetails(autoselectKey) {
<h4 style="margin-top: 20px; margin-bottom: 10px;">Available Models</h4> <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> <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> <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); renderAutoselectModels(autoselectKey);
...@@ -270,23 +305,20 @@ function renderAutoselectModels(autoselectKey) { ...@@ -270,23 +305,20 @@ function renderAutoselectModels(autoselectKey) {
autoselect.available_models.forEach((model, index) => { autoselect.available_models.forEach((model, index) => {
const modelDiv = document.createElement('div'); const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;'; modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
const safeKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const modelOptions = availableModels.map(m => const allOptions = availableRotations.map(r => ({id: r, name: r})).concat(availableModels);
`<option value="${m.id}" ${model.model_id === m.id ? 'selected' : ''}>${m.name}</option>` const selUid = `asel-mod-${autoselectKey}-${index}`;
).join('');
modelDiv.innerHTML = ` modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong>Model ${index + 1}</strong> <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>
<div class="form-group"> <div class="form-group">
<label>Model ID (Rotation or Provider Model)</label> <label>Model ID (Rotation or Provider Model)</label>
<select onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'model_id', this.value)" required> ${buildModelSelectHtml(selUid, model.model_id || '', allOptions,
<option value="">Select model...</option> `function(v){updateAutoselectModel('${safeKey}',${index},'model_id',v)}`)}
${modelOptions}
</select>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small> <small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small>
</div> </div>
...@@ -315,31 +347,62 @@ function renderAutoselectModels(autoselectKey) { ...@@ -315,31 +347,62 @@ function renderAutoselectModels(autoselectKey) {
}); });
} }
async function addAutoselect() { async function apiCall(method, url, body) {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect'); const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (!key) { if (body !== undefined) opts.body = JSON.stringify(body);
return; 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]) { if (autoselectConfig[key]) {
showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn'); showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
const newCfg = { model_name: key, description: '', selection_model: '', fallback: '', available_models: [] };
autoselectConfig[key] = { autoselectConfig[key] = newCfg;
model_name: key, try {
description: '', const result = await apiCall('POST', '/dashboard/api/autoselect', { autoselect_id: key, config: newCfg });
selection_model: '', if (!result.success) {
fallback: '', showAlert('Error creating autoselect: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
available_models: [] delete autoselectConfig[key];
}; return;
}
} catch (e) {
showAlert('Error creating autoselect: ' + e.message, 'Error', '❌', 'danger');
delete autoselectConfig[key];
return;
}
expandedAutoselects.add(key); expandedAutoselects.add(key);
renderAutoselectList(); renderAutoselectList();
} }
async function removeAutoselect(key) { async function removeAutoselect(key) {
if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) { 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]; delete autoselectConfig[key];
expandedAutoselects.delete(key); expandedAutoselects.delete(key);
renderAutoselectList(); renderAutoselectList();
......
...@@ -79,11 +79,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -79,11 +79,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</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 --> <!-- Provider list will be rendered here -->
</div> </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;"> <div style="display: flex; gap: 10px; margin-top: 20px;">
<button type="button" class="btn" onclick="saveProviders()">Save Configuration</button> <button type="button" class="btn" onclick="saveProviders()">Save Configuration</button>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a> <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/>. ...@@ -135,9 +144,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block extra_js %} {% block extra_js %}
<script> <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' }}; const CLAUDE_CLI_MODE = {{ 'true' if claude_cli_mode else 'false' }};
let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }}; let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }};
let expandedProviders = new Set(); let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = '';
// Chunk size: 512KB chunks for maximum compatibility with restrictive proxies // Chunk size: 512KB chunks for maximum compatibility with restrictive proxies
const CHUNK_SIZE = 512 * 1024; const CHUNK_SIZE = 512 * 1024;
...@@ -275,37 +298,47 @@ async function uploadCodexFile(providerKey, file) { ...@@ -275,37 +298,47 @@ async function uploadCodexFile(providerKey, file) {
} }
function renderProvidersList() { function renderProvidersList() {
console.log('=== renderProvidersList START ===');
const container = document.getElementById('providers-list'); const container = document.getElementById('providers-list');
console.log('Container element:', container); if (!container) return;
if (!container) { const allKeys = Object.keys(providersData);
console.error('ERROR: providers-list container not found!'); const filteredKeys = providerSearchFilter
return; ? allKeys.filter(k => k.toLowerCase().includes(providerSearchFilter.toLowerCase()) ||
} (providersData[k].name || '').toLowerCase().includes(providerSearchFilter.toLowerCase()))
: allKeys;
container.innerHTML = ''; const total = filteredKeys.length;
console.log('Cleared container, processing', Object.keys(providersData).length, 'providers'); 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]) => { const countEl = document.getElementById('providers-count');
console.log('Processing provider:', key); 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'); const providerItem = document.createElement('div');
providerItem.className = 'provider-item'; providerItem.className = 'provider-item';
providerItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;'; providerItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;';
const isExpanded = expandedProviders.has(key); const isExpanded = expandedProviders.has(key);
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
providerItem.innerHTML = ` 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;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span> <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;">(${provider.name || key})</span> <span style="color: #a0a0a0; font-size: 14px;">(${escHtmlAttr(provider.name || key)})</span>
</div> </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>
<div id="provider-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="provider-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
</div> </div>
`; `;
...@@ -315,6 +348,35 @@ function renderProvidersList() { ...@@ -315,6 +348,35 @@ function renderProvidersList() {
renderProviderDetails(key); 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) { function toggleProvider(key) {
...@@ -322,6 +384,14 @@ function toggleProvider(key) { ...@@ -322,6 +384,14 @@ function toggleProvider(key) {
expandedProviders.delete(key); expandedProviders.delete(key);
} else { } else {
expandedProviders.add(key); 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(); renderProvidersList();
} }
...@@ -329,6 +399,7 @@ function toggleProvider(key) { ...@@ -329,6 +399,7 @@ function toggleProvider(key) {
function renderProviderDetails(key) { function renderProviderDetails(key) {
const container = document.getElementById(`provider-details-${key}`); const container = document.getElementById(`provider-details-${key}`);
const provider = providersData[key]; const provider = providersData[key];
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const isKiroProvider = provider.type === 'kiro'; const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude'; const isClaudeProvider = provider.type === 'claude';
const isKiloProvider = provider.type === 'kilocode'; const isKiloProvider = provider.type === 'kilocode';
...@@ -933,13 +1004,18 @@ function renderProviderDetails(key) { ...@@ -933,13 +1004,18 @@ function renderProviderDetails(key) {
</div> </div>
<div id="models-${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;"> <button type="button" class="btn" onclick="getModelsFromProvider('${key}')" style="background: #4a9eff;">
🔄 Get Models from Provider 🔄 Get Models from Provider
</button> </button>
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')">Add Model Manually</button> <button type="button" class="btn btn-secondary" onclick="addModel('${key}')">Add Model Manually</button>
</div> </div>
<div id="get-models-status-${key}" style="margin-top: 10px;"></div> <div id="get-models-status-${key}" style="margin-top: 10px;"></div>
<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); renderModels(key);
...@@ -1687,8 +1763,36 @@ async function pollKiloAuth(providerKey, deviceCode) { ...@@ -1687,8 +1763,36 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo // 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) { async function removeProvider(key) {
if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) { 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]; delete providersData[key];
expandedProviders.delete(key); expandedProviders.delete(key);
renderProvidersList(); renderProvidersList();
...@@ -1834,9 +1938,24 @@ async function confirmAddProvider() { ...@@ -1834,9 +1938,24 @@ async function confirmAddProvider() {
}; };
} }
// Add to expanded providers so it automatically expands // Immediately persist the new provider
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(); cancelAddProvider();
renderProvidersList(); renderProvidersList();
} }
...@@ -1927,12 +2046,7 @@ async function saveProviders() { ...@@ -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(); renderProvidersList();
console.log('renderProvidersList() completed');
</script> </script>
{% endblock %} {% endblock %}
...@@ -47,6 +47,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -47,6 +47,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
<script> <script>
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const rotationsData = { const rotationsData = {
config: {{ rotations_json | safe }}, config: {{ rotations_json | safe }},
providers: {{ available_providers | safe }}, providers: {{ available_providers | safe }},
...@@ -57,6 +61,61 @@ let rotationsConfig = rotationsData.config; ...@@ -57,6 +61,61 @@ let rotationsConfig = rotationsData.config;
let availableProviders = rotationsData.providers; let availableProviders = rotationsData.providers;
let expandedRotations = new Set(); 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; document.getElementById('global-notify').checked = rotationsConfig.notifyerrors || false;
function renderRotationsList() { function renderRotationsList() {
...@@ -70,18 +129,18 @@ function renderRotationsList() { ...@@ -70,18 +129,18 @@ function renderRotationsList() {
const isExpanded = expandedRotations.has(key); const isExpanded = expandedRotations.has(key);
const providerCount = rotation.providers ? rotation.providers.length : 0; const providerCount = rotation.providers ? rotation.providers.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
rotationItem.innerHTML = ` 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;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span> <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> <span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span>
</div> </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>
<div id="rotation-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
</div> </div>
`; `;
...@@ -105,6 +164,7 @@ function toggleRotation(key) { ...@@ -105,6 +164,7 @@ function toggleRotation(key) {
function renderRotationDetails(rotationKey) { function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`); const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey]; const rotation = rotationsConfig.rotations[rotationKey];
const safeRKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
container.innerHTML = ` container.innerHTML = `
<div class="form-group"> <div class="form-group">
...@@ -150,7 +210,12 @@ function renderRotationDetails(rotationKey) { ...@@ -150,7 +210,12 @@ function renderRotationDetails(rotationKey) {
<h4 style="margin-top: 20px; margin-bottom: 10px;">Providers</h4> <h4 style="margin-top: 20px; margin-bottom: 10px;">Providers</h4>
<div id="providers-${rotationKey}"></div> <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); renderRotationProviders(rotationKey);
...@@ -169,33 +234,29 @@ function renderRotationProviders(rotationKey) { ...@@ -169,33 +234,29 @@ function renderRotationProviders(rotationKey) {
rotation.providers.forEach((provider, providerIndex) => { rotation.providers.forEach((provider, providerIndex) => {
const providerDiv = document.createElement('div'); const providerDiv = document.createElement('div');
providerDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;'; providerDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const providerOptions = availableProviders.map(p => const provSelUid = `prov-sel-${rotationKey}-${providerIndex}`;
`<option value="${p}" ${provider.provider_id === p ? 'selected' : ''}>${p}</option>`
).join('');
providerDiv.innerHTML = ` providerDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong>Provider ${providerIndex + 1}</strong> <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>
<div class="form-group"> <div class="form-group">
<label>Provider ID</label> <label>Provider ID</label>
<select onchange="updateRotationProvider('${rotationKey}', ${providerIndex}, 'provider_id', this.value)" required> ${buildProviderSelectHtml(provSelUid, provider.provider_id || '',
<option value="">Select provider...</option> `function(v){updateRotationProvider('${safeKey}',${providerIndex},'provider_id',v)}`)}
${providerOptions}
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Weight (optional, for provider-level weight)</label> <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> </div>
<h5 style="margin-top: 15px; margin-bottom: 10px;">Models</h5> <h5 style="margin-top: 15px; margin-bottom: 10px;">Models</h5>
<div id="models-${rotationKey}-${providerIndex}"></div> <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> <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) { ...@@ -217,16 +278,25 @@ function renderRotationModels(rotationKey, providerIndex) {
provider.models.forEach((model, modelIndex) => { provider.models.forEach((model, modelIndex) => {
const modelDiv = document.createElement('div'); const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 10px; margin-bottom: 8px; border-radius: 3px; background: #16213e;'; 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 = ` modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="font-size: 13px;">Model ${modelIndex + 1}</strong> <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>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">Model Name</label> <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>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
...@@ -268,6 +338,31 @@ function updateGlobalNotify(value) { ...@@ -268,6 +338,31 @@ function updateGlobalNotify(value) {
rotationsConfig.notifyerrors = 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() { async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation'); const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return; if (!key) return;
...@@ -275,24 +370,31 @@ async function addRotation() { ...@@ -275,24 +370,31 @@ async function addRotation() {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn'); showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
if (!rotationsConfig.rotations) rotationsConfig.rotations = {};
if (!rotationsConfig.rotations) { const newCfg = { model_name: key, notifyerrors: false, capabilities: [], providers: [] };
rotationsConfig.rotations = {}; 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); expandedRotations.add(key);
renderRotationsList(); renderRotationsList();
} }
async function removeRotation(key) { async function removeRotation(key) {
if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) { 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]; delete rotationsConfig.rotations[key];
expandedRotations.delete(key); expandedRotations.delete(key);
renderRotationsList(); renderRotationsList();
......
...@@ -43,6 +43,43 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -43,6 +43,43 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
function escHtml(s) { function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); 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 = { const autoselectData = {
config: {{ autoselect_json | safe }}, config: {{ autoselect_json | safe }},
...@@ -72,17 +109,18 @@ function renderAutoselectList() { ...@@ -72,17 +109,18 @@ function renderAutoselectList() {
const isExpanded = expandedAutoselects.has(key); const isExpanded = expandedAutoselects.has(key);
const modelCount = autoselect.available_models ? autoselect.available_models.length : 0; const modelCount = autoselect.available_models ? autoselect.available_models.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
autoselectItem.innerHTML = ` 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;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span> <span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${escHtml(autoselect.model_name || key)}</strong> <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> <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('${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>
<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 --> <!-- Details will be rendered here -->
</div> </div>
`; `;
...@@ -107,6 +145,7 @@ function toggleAutoselect(key) { ...@@ -107,6 +145,7 @@ function toggleAutoselect(key) {
function renderAutoselectDetails(autoselectKey) { function renderAutoselectDetails(autoselectKey) {
const container = document.getElementById(`autoselect-details-${autoselectKey}`); const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey]; const autoselect = autoselectConfig[autoselectKey];
const safeAKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
// Default to "internal" if selection_model is not set // Default to "internal" if selection_model is not set
const selectionValue = autoselect.selection_model || 'internal'; const selectionValue = autoselect.selection_model || 'internal';
...@@ -255,7 +294,12 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -255,7 +294,12 @@ function renderAutoselectDetails(autoselectKey) {
<h4 style="margin-top: 20px; margin-bottom: 10px;">Available Models</h4> <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> <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> <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); renderAutoselectModels(autoselectKey);
...@@ -274,23 +318,20 @@ function renderAutoselectModels(autoselectKey) { ...@@ -274,23 +318,20 @@ function renderAutoselectModels(autoselectKey) {
autoselect.available_models.forEach((model, index) => { autoselect.available_models.forEach((model, index) => {
const modelDiv = document.createElement('div'); const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;'; modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
const safeKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const modelOptions = availableModels.map(m => const allOptions = availableRotations.map(r => ({id: r, name: r})).concat(availableModels);
`<option value="${m.id}" ${model.model_id === m.id ? 'selected' : ''}>${m.name}</option>` const selUid = `asel-mod-${autoselectKey}-${index}`;
).join('');
modelDiv.innerHTML = ` modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong>Model ${index + 1}</strong> <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>
<div class="form-group"> <div class="form-group">
<label>Model ID (Rotation or Provider Model)</label> <label>Model ID (Rotation or Provider Model)</label>
<select onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'model_id', this.value)" required> ${buildModelSelectHtml(selUid, model.model_id || '', allOptions,
<option value="">Select model...</option> `function(v){updateAutoselectModel('${safeKey}',${index},'model_id',v)}`)}
${modelOptions}
</select>
<small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small> <small style="color: #a0a0a0; font-size: 12px; display: block; margin-top: 5px;">Choose from rotations or provider models</small>
</div> </div>
...@@ -319,31 +360,55 @@ function renderAutoselectModels(autoselectKey) { ...@@ -319,31 +360,55 @@ function renderAutoselectModels(autoselectKey) {
}); });
} }
async function addAutoselect() { async function saveAutoselectItem(key) {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect'); const statusEl = document.getElementById(`asel-save-status-${key}`);
if (!key) { if (statusEl) statusEl.textContent = 'Saving…';
return; 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]) { if (autoselectConfig[key]) {
showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn'); showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
const newCfg = { model_name: key, description: '', selection_model: '', fallback: '', available_models: [] };
autoselectConfig[key] = { autoselectConfig[key] = newCfg;
model_name: key, try {
description: '', const result = await apiCall('POST', '/dashboard/api/autoselect', { autoselect_id: key, config: newCfg });
selection_model: '', if (!result.success) {
fallback: '', showAlert('Error creating autoselect: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
available_models: [] delete autoselectConfig[key];
}; return;
}
} catch (e) {
showAlert('Error creating autoselect: ' + e.message, 'Error', '❌', 'danger');
delete autoselectConfig[key];
return;
}
expandedAutoselects.add(key); expandedAutoselects.add(key);
renderAutoselectList(); renderAutoselectList();
} }
async function removeAutoselect(key) { async function removeAutoselect(key) {
if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) { 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]; delete autoselectConfig[key];
expandedAutoselects.delete(key); expandedAutoselects.delete(key);
renderAutoselectList(); renderAutoselectList();
......
...@@ -79,11 +79,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -79,11 +79,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</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 --> <!-- Provider list will be rendered here -->
</div> </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;"> <div style="display: flex; gap: 10px; margin-top: 20px;">
<button type="button" class="btn" onclick="saveProviders()">Save Configuration</button> <button type="button" class="btn" onclick="saveProviders()">Save Configuration</button>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
...@@ -252,8 +261,22 @@ function showToast(message, type) { ...@@ -252,8 +261,22 @@ function showToast(message, type) {
}, 4000); }, 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 providersData = {};
let expandedProviders = new Set(); let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = '';
let rawProviders = {{ user_providers_json | replace("</script>", "<\\/script>") | safe }}; let rawProviders = {{ user_providers_json | replace("</script>", "<\\/script>") | safe }};
// Convert user providers format to the format expected by the UI // Convert user providers format to the format expected by the UI
...@@ -397,37 +420,47 @@ async function uploadCodexFile(providerKey, file) { ...@@ -397,37 +420,47 @@ async function uploadCodexFile(providerKey, file) {
} }
function renderProvidersList() { function renderProvidersList() {
console.log('=== renderProvidersList START ===');
const container = document.getElementById('providers-list'); const container = document.getElementById('providers-list');
console.log('Container element:', container); if (!container) return;
if (!container) { const allKeys = Object.keys(providersData);
console.error('ERROR: providers-list container not found!'); const filteredKeys = providerSearchFilter
return; ? allKeys.filter(k => k.toLowerCase().includes(providerSearchFilter.toLowerCase()) ||
} (providersData[k].name || '').toLowerCase().includes(providerSearchFilter.toLowerCase()))
: allKeys;
container.innerHTML = ''; const total = filteredKeys.length;
console.log('Cleared container, processing', Object.keys(providersData).length, 'providers'); 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]) => { const countEl = document.getElementById('providers-count');
console.log('Processing provider:', key); 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'); const providerItem = document.createElement('div');
providerItem.className = 'provider-item'; providerItem.className = 'provider-item';
providerItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;'; providerItem.style.cssText = 'border: 1px solid #0f3460; margin-bottom: 10px; border-radius: 5px; background: #1a1a2e;';
const isExpanded = expandedProviders.has(key); const isExpanded = expandedProviders.has(key);
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
providerItem.innerHTML = ` 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;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span> <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;">(${provider.name || key})</span> <span style="color: #a0a0a0; font-size: 14px;">(${escHtmlAttr(provider.name || key)})</span>
</div> </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>
<div id="provider-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="provider-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
</div> </div>
`; `;
...@@ -437,6 +470,34 @@ function renderProvidersList() { ...@@ -437,6 +470,34 @@ function renderProvidersList() {
renderProviderDetails(key); 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) { function toggleProvider(key) {
...@@ -444,6 +505,13 @@ function toggleProvider(key) { ...@@ -444,6 +505,13 @@ function toggleProvider(key) {
expandedProviders.delete(key); expandedProviders.delete(key);
} else { } else {
expandedProviders.add(key); 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(); renderProvidersList();
} }
...@@ -451,6 +519,7 @@ function toggleProvider(key) { ...@@ -451,6 +519,7 @@ function toggleProvider(key) {
function renderProviderDetails(key) { function renderProviderDetails(key) {
const container = document.getElementById(`provider-details-${key}`); const container = document.getElementById(`provider-details-${key}`);
const provider = providersData[key]; const provider = providersData[key];
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const isKiroProvider = provider.type === 'kiro'; const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude'; const isClaudeProvider = provider.type === 'claude';
const isKiloProvider = provider.type === 'kilocode'; const isKiloProvider = provider.type === 'kilocode';
...@@ -1020,6 +1089,11 @@ function renderProviderDetails(key) { ...@@ -1020,6 +1089,11 @@ function renderProviderDetails(key) {
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')">Add Model Manually</button> <button type="button" class="btn btn-secondary" onclick="addModel('${key}')">Add Model Manually</button>
</div> </div>
<div id="get-models-status-${key}" style="margin-top: 10px;"></div> <div id="get-models-status-${key}" style="margin-top: 10px;"></div>
<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); renderModels(key);
...@@ -1776,8 +1850,36 @@ async function pollKiloAuth(providerKey, deviceCode) { ...@@ -1776,8 +1850,36 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo // 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) { async function removeProvider(key) {
if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) { 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]; delete providersData[key];
expandedProviders.delete(key); expandedProviders.delete(key);
renderProvidersList(); renderProvidersList();
...@@ -1916,9 +2018,23 @@ async function confirmAddProvider() { ...@@ -1916,9 +2018,23 @@ async function confirmAddProvider() {
}; };
} }
// Add to expanded providers so it automatically expands try {
expandedProviders.add(key); 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(); cancelAddProvider();
renderProvidersList(); renderProvidersList();
} }
...@@ -2009,14 +2125,9 @@ async function saveProviders() { ...@@ -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() { async function initProviders() {
console.log('Initializing providers list...');
await loadCacheSettings(); await loadCacheSettings();
console.log('providersData:', Object.keys(providersData));
console.log('Number of providers:', Object.keys(providersData).length);
renderProvidersList(); renderProvidersList();
console.log('renderProvidersList() completed');
} }
initProviders(); initProviders();
......
...@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -40,6 +40,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
<script> <script>
function escHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
const rotationsData = { const rotationsData = {
config: {{ rotations_json | safe }}, config: {{ rotations_json | safe }},
providers: {{ available_providers | safe }}, providers: {{ available_providers | safe }},
...@@ -50,6 +54,59 @@ let rotationsConfig = rotationsData.config; ...@@ -50,6 +54,59 @@ let rotationsConfig = rotationsData.config;
let availableProviders = rotationsData.providers; let availableProviders = rotationsData.providers;
let expandedRotations = new Set(); 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() { function renderRotationsList() {
const container = document.getElementById('rotations-list'); const container = document.getElementById('rotations-list');
container.innerHTML = ''; container.innerHTML = '';
...@@ -61,18 +118,18 @@ function renderRotationsList() { ...@@ -61,18 +118,18 @@ function renderRotationsList() {
const isExpanded = expandedRotations.has(key); const isExpanded = expandedRotations.has(key);
const providerCount = rotation.providers ? rotation.providers.length : 0; const providerCount = rotation.providers ? rotation.providers.length : 0;
const safeKey = key.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
rotationItem.innerHTML = ` 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;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span> <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> <span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span>
</div> </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>
<div id="rotation-details-${key}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here -->
</div> </div>
`; `;
...@@ -96,6 +153,7 @@ function toggleRotation(key) { ...@@ -96,6 +153,7 @@ function toggleRotation(key) {
function renderRotationDetails(rotationKey) { function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`); const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey]; const rotation = rotationsConfig.rotations[rotationKey];
const safeRKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
container.innerHTML = ` container.innerHTML = `
<div class="form-group"> <div class="form-group">
...@@ -145,7 +203,12 @@ function renderRotationDetails(rotationKey) { ...@@ -145,7 +203,12 @@ function renderRotationDetails(rotationKey) {
<h4 style="margin-top: 20px; margin-bottom: 10px;">Providers</h4> <h4 style="margin-top: 20px; margin-bottom: 10px;">Providers</h4>
<div id="providers-${rotationKey}"></div> <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); renderRotationProviders(rotationKey);
...@@ -164,33 +227,29 @@ function renderRotationProviders(rotationKey) { ...@@ -164,33 +227,29 @@ function renderRotationProviders(rotationKey) {
rotation.providers.forEach((provider, providerIndex) => { rotation.providers.forEach((provider, providerIndex) => {
const providerDiv = document.createElement('div'); const providerDiv = document.createElement('div');
providerDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;'; providerDiv.style.cssText = 'border: 1px solid #0f3460; padding: 15px; margin-bottom: 10px; border-radius: 3px; background: #1a1a2e;';
const safeKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const providerOptions = availableProviders.map(p => const provSelUid = `prov-sel-${rotationKey}-${providerIndex}`;
`<option value="${p}" ${provider.provider_id === p ? 'selected' : ''}>${p}</option>`
).join('');
providerDiv.innerHTML = ` providerDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong>Provider ${providerIndex + 1}</strong> <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>
<div class="form-group"> <div class="form-group">
<label>Provider ID</label> <label>Provider ID</label>
<select onchange="updateRotationProvider('${rotationKey}', ${providerIndex}, 'provider_id', this.value)" required> ${buildProviderSelectHtml(provSelUid, provider.provider_id || '',
<option value="">Select provider...</option> `function(v){updateRotationProvider('${safeKey}',${providerIndex},'provider_id',v)}`)}
${providerOptions}
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Weight (optional, for provider-level weight)</label> <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> </div>
<h5 style="margin-top: 15px; margin-bottom: 10px;">Models</h5> <h5 style="margin-top: 15px; margin-bottom: 10px;">Models</h5>
<div id="models-${rotationKey}-${providerIndex}"></div> <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> <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) { ...@@ -212,16 +271,25 @@ function renderRotationModels(rotationKey, providerIndex) {
provider.models.forEach((model, modelIndex) => { provider.models.forEach((model, modelIndex) => {
const modelDiv = document.createElement('div'); const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid #0f3460; padding: 10px; margin-bottom: 8px; border-radius: 3px; background: #16213e;'; 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 = ` modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="font-size: 13px;">Model ${modelIndex + 1}</strong> <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>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">Model Name</label> <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>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
...@@ -259,6 +327,31 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -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() { async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation'); const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return; if (!key) return;
...@@ -266,24 +359,31 @@ async function addRotation() { ...@@ -266,24 +359,31 @@ async function addRotation() {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn'); showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
if (!rotationsConfig.rotations) rotationsConfig.rotations = {};
if (!rotationsConfig.rotations) { const newCfg = { model_name: key, notifyerrors: false, capabilities: [], providers: [] };
rotationsConfig.rotations = {}; 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); expandedRotations.add(key);
renderRotationsList(); renderRotationsList();
} }
async function removeRotation(key) { async function removeRotation(key) {
if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) { 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]; delete rotationsConfig.rotations[key];
expandedRotations.delete(key); expandedRotations.delete(key);
renderRotationsList(); 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