Minimal template fixes

parent 6130a6db
...@@ -18,5 +18,6 @@ recursive-include templates *.css ...@@ -18,5 +18,6 @@ recursive-include templates *.css
recursive-include templates *.js recursive-include templates *.js
recursive-include static *.zip recursive-include static *.zip
recursive-include static *.js recursive-include static *.js
recursive-include static *.css
recursive-include static/i18n *.json recursive-include static/i18n *.json
recursive-include static/extension *.js *.json *.html *.md *.png *.svg recursive-include static/extension *.js *.json *.html *.md *.png *.svg
...@@ -962,6 +962,8 @@ class Analytics: ...@@ -962,6 +962,8 @@ class Analytics:
Returns: Returns:
List of model performance data List of model performance data
""" """
start, end, _ = self._normalize_time_window('custom' if from_datetime or to_datetime else '24h', from_datetime, to_datetime)
# Always query token_usage for provider/model combinations within the time range # Always query token_usage for provider/model combinations within the time range
# context_dimensions is used only for metadata (context_size, condense settings) # context_dimensions is used only for metadata (context_size, condense settings)
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
...@@ -975,12 +977,10 @@ class Analytics: ...@@ -975,12 +977,10 @@ class Analytics:
''' '''
params = [] params = []
if from_datetime: query += f' AND timestamp >= {placeholder}'
query += f' AND timestamp >= {placeholder}' params.append(self._format_timestamp(start))
params.append(self._format_timestamp(from_datetime)) query += f' AND timestamp <= {placeholder}'
if to_datetime: params.append(self._format_timestamp(end))
query += f' AND timestamp <= {placeholder}'
params.append(self._format_timestamp(to_datetime))
if user_filter == -1: if user_filter == -1:
query += ' AND user_id IS NULL' query += ' AND user_id IS NULL'
elif user_filter is not None: elif user_filter is not None:
...@@ -1003,7 +1003,15 @@ class Analytics: ...@@ -1003,7 +1003,15 @@ class Analytics:
query += ' ORDER BY provider_id, model_name' query += ' ORDER BY provider_id, model_name'
cursor.execute(query, params) cursor.execute(query, params)
active_combos = {(row[0], row[1]): {'rotation_id': row[2], 'autoselect_id': row[3]} for row in cursor.fetchall()} active_combos = [
{
'provider_id': row[0],
'model_name': row[1],
'rotation_id': row[2],
'autoselect_id': row[3],
}
for row in cursor.fetchall()
]
# Build context_dims lookup from context_dimensions table (for metadata only) # Build context_dims lookup from context_dimensions table (for metadata only)
raw_dims = self.db.get_all_context_dimensions(user_filter=user_filter) raw_dims = self.db.get_all_context_dimensions(user_filter=user_filter)
...@@ -1011,7 +1019,9 @@ class Analytics: ...@@ -1011,7 +1019,9 @@ class Analytics:
# Build context_dims from active token_usage combinations, enriched with context metadata # Build context_dims from active token_usage combinations, enriched with context metadata
context_dims = [] context_dims = []
for (pid, mname), extra in active_combos.items(): for extra in active_combos:
pid = extra['provider_id']
mname = extra['model_name']
meta = dim_lookup.get((pid, mname), {}) meta = dim_lookup.get((pid, mname), {})
context_dims.append({ context_dims.append({
'provider_id': pid, 'provider_id': pid,
...@@ -1053,8 +1063,8 @@ class Analytics: ...@@ -1053,8 +1063,8 @@ class Analytics:
continue continue
combo_stats = next((item for item in self.get_all_providers_stats( combo_stats = next((item for item in self.get_all_providers_stats(
from_datetime, start,
to_datetime, end,
user_filter=user_filter, user_filter=user_filter,
provider_filter=provider_id, provider_filter=provider_id,
model_filter=model_name, model_filter=model_name,
...@@ -1237,8 +1247,8 @@ class Analytics: ...@@ -1237,8 +1247,8 @@ class Analytics:
start, end, _ = self._normalize_time_window('custom' if from_datetime or to_datetime else '24h', from_datetime, to_datetime) start, end, _ = self._normalize_time_window('custom' if from_datetime or to_datetime else '24h', from_datetime, to_datetime)
provider_stats = self.get_all_providers_stats( provider_stats = self.get_all_providers_stats(
from_datetime, start,
to_datetime, end,
user_filter=user_filter, user_filter=user_filter,
provider_filter=provider_filter, provider_filter=provider_filter,
model_filter=model_filter, model_filter=model_filter,
...@@ -1271,9 +1281,6 @@ class Analytics: ...@@ -1271,9 +1281,6 @@ class Analytics:
if not tier_info: if not tier_info:
continue continue
if tier_info.get('source') == 'default':
continue
cached_usage = None cached_usage = None
try: try:
cached_row = self.db.get_provider_usage(None if user_filter == -1 else user_filter, provider_id) cached_row = self.db.get_provider_usage(None if user_filter == -1 else user_filter, provider_id)
...@@ -1290,6 +1297,10 @@ class Analytics: ...@@ -1290,6 +1297,10 @@ class Analytics:
if free_limit <= 0: if free_limit <= 0:
continue continue
premium_price = float(tier_info.get('premium_monthly_cost', 0) or 0)
if premium_price <= 0:
continue
if limit_type == 'tokens': if limit_type == 'tokens':
usage_amount = usage.get('tokens_used', 0) or 0 usage_amount = usage.get('tokens_used', 0) or 0
else: else:
...@@ -1302,9 +1313,11 @@ class Analytics: ...@@ -1302,9 +1313,11 @@ class Analytics:
if extra_free_tiers <= 0: if extra_free_tiers <= 0:
continue continue
if tier_info.get('source') == 'default' and usage_amount > free_limit * 10:
continue
saved_tokens = usage['tokens_used'] * extra_free_tiers saved_tokens = usage['tokens_used'] * extra_free_tiers
equivalent_token_savings += saved_tokens equivalent_token_savings += saved_tokens
premium_price = float(tier_info.get('premium_monthly_cost', 0) or 0)
premium_equivalent_cost = premium_price * extra_free_tiers premium_equivalent_cost = premium_price * extra_free_tiers
provider_equivalents.append({ provider_equivalents.append({
'provider_id': provider_id, 'provider_id': provider_id,
......
...@@ -6,7 +6,7 @@ import time ...@@ -6,7 +6,7 @@ import time
import asyncio import asyncio
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, Body
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from aisbf.coderai_broker import broker from aisbf.coderai_broker import broker
...@@ -17,6 +17,26 @@ router = APIRouter() ...@@ -17,6 +17,26 @@ router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _coderai_register_payload(
provider_id: str,
client_id: str,
username: str,
owner_user_id: Optional[int],
scope_name: str,
) -> dict:
return {
"v": 1,
"event": "registered",
"provider_id": provider_id,
"client_id": client_id,
"username": username,
"scope_name": scope_name,
"accepted": True,
"owner_user_id": owner_user_id,
"expires_at": int(time.time()) + 86400,
}
async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str): async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
provider_id = websocket.query_params.get("provider_id") or websocket.headers.get("x-coderai-provider-id") or "coderai" provider_id = websocket.query_params.get("provider_id") or websocket.headers.get("x-coderai-provider-id") or "coderai"
client_id = websocket.query_params.get("client_id") or websocket.headers.get("x-coderai-client-id") or f"anon-{int(time.time())}" client_id = websocket.query_params.get("client_id") or websocket.headers.get("x-coderai-client-id") or f"anon-{int(time.time())}"
...@@ -26,21 +46,14 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str): ...@@ -26,21 +46,14 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
if not valid: if not valid:
await websocket.close(code=1008, reason=error or "registration rejected") await websocket.close(code=1008, reason=error or "registration rejected")
return return
await websocket.accept() await websocket.accept()
expected_scope = scope_name expected_scope = scope_name
session = await broker.register(websocket, provider_id, client_id, metadata={"source": "websocket", "owner_user_id": owner_user_id, "username": username, "scope_name": expected_scope, "proxy_scheme": websocket.url.scheme}) session = await broker.register(websocket, provider_id, client_id, metadata={"source": "websocket", "owner_user_id": owner_user_id, "username": username, "scope_name": expected_scope, "proxy_scheme": websocket.url.scheme})
try: try:
await websocket.send_text(json.dumps({ payload = _coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope)
"v": 1, payload["session_id"] = session.session_id
"event": "registered", await websocket.send_text(json.dumps(payload))
"session_id": session.session_id,
"provider_id": session.provider_id,
"client_id": session.client_id,
"username": username,
"scope_name": expected_scope,
"accepted": True,
}))
while True: while True:
try: try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=1.0) raw = await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
...@@ -78,14 +91,8 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str): ...@@ -78,14 +91,8 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
"request_id": message.get("request_id"), "request_id": message.get("request_id"),
"status": "ok", "status": "ok",
"payload": { "payload": {
"accepted": True, **_coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope),
"session_id": session.session_id, "session_id": session.session_id,
"provider_id": session.provider_id,
"client_id": session.client_id,
"owner_user_id": owner_user_id,
"username": username,
"scope_name": expected_scope,
"expires_at": int(time.time()) + 86400,
}, },
})) }))
continue continue
...@@ -126,6 +133,30 @@ async def coderai_broker_websocket_user(websocket: WebSocket, username: str): ...@@ -126,6 +133,30 @@ async def coderai_broker_websocket_user(websocket: WebSocket, username: str):
await _coderai_broker_websocket_impl(websocket, username) await _coderai_broker_websocket_impl(websocket, username)
@router.post("/api/coderai/register")
async def coderai_register_global(body: dict = Body(default={})): # nosec B008
provider_id = body.get("provider_id") or "coderai"
client_id = body.get("client_id") or f"anon-{int(time.time())}"
username = body.get("username") or "global"
presented_token = body.get("registration_token")
valid, owner_user_id, _provider_config, error = validate_coderai_registration_token(provider_id, presented_token, username=username)
if not valid:
raise HTTPException(status_code=403, detail=error or "registration rejected")
return _coderai_register_payload(provider_id, client_id, username, owner_user_id, "global")
@router.post("/api/u/{username}/coderai/register")
async def coderai_register_user(username: str, body: dict = Body(default={})): # nosec B008
provider_id = body.get("provider_id") or "coderai"
client_id = body.get("client_id") or f"anon-{int(time.time())}"
presented_token = body.get("registration_token")
effective_username = body.get("username") or username
valid, owner_user_id, _provider_config, error = validate_coderai_registration_token(provider_id, presented_token, username=effective_username)
if not valid:
raise HTTPException(status_code=403, detail=error or "registration rejected")
return _coderai_register_payload(provider_id, client_id, effective_username, owner_user_id, effective_username)
@router.get("/api/coderai/broker/sessions") @router.get("/api/coderai/broker/sessions")
async def coderai_broker_sessions(): async def coderai_broker_sessions():
return {"sessions": await broker.list_sessions()} return {"sessions": await broker.list_sessions()}
......
This diff is collapsed.
...@@ -595,7 +595,7 @@ async def dashboard_studio(request: Request): ...@@ -595,7 +595,7 @@ async def dashboard_studio(request: Request):
"session": request.session, "session": request.session,
"__version__": __version__, "__version__": __version__,
"studio_bootstrap_json": json.dumps(catalog), "studio_bootstrap_json": json.dumps(catalog),
"studio_root_path_json": json.dumps("/api/v1") if is_config_admin else json.dumps(f"/api/u/{request.session.get('username', '')}"), "studio_root_path_json": json.dumps("/dashboard/api/studio") if is_config_admin else json.dumps(f"/dashboard/api/studio/u/{request.session.get('username', '')}"),
"studio_username_json": json.dumps(request.session.get("username", "")), "studio_username_json": json.dumps(request.session.get("username", "")),
"studio_is_global_admin_json": json.dumps(is_config_admin), "studio_is_global_admin_json": json.dumps(is_config_admin),
"studio_system_prompt_json": json.dumps(studio_service.load_studio_system_prompt(scope, current_user_id)), "studio_system_prompt_json": json.dumps(studio_service.load_studio_system_prompt(scope, current_user_id)),
......
...@@ -47,7 +47,7 @@ class StudioService: ...@@ -47,7 +47,7 @@ class StudioService:
"category": "chat", "category": "chat",
"endpoint": "/chat/completions", "endpoint": "/chat/completions",
"roles": [ "roles": [
{"key": "model", "label": "Chat model", "capabilities": ["text_generation"]}, {"key": "model", "label": "Chat model", "capabilities": ["text_generation", "chat"]},
], ],
}, },
{ {
...@@ -350,7 +350,7 @@ class StudioService: ...@@ -350,7 +350,7 @@ class StudioService:
"endpoint": "/pipelines/audio-understand", "endpoint": "/pipelines/audio-understand",
"roles": [ "roles": [
{"key": "audio_model", "label": "Audio model", "capabilities": ["speech_to_text"]}, {"key": "audio_model", "label": "Audio model", "capabilities": ["speech_to_text"]},
{"key": "text_model", "label": "Reasoning model", "capabilities": ["text_generation"], "optional": True}, {"key": "text_model", "label": "Reasoning model", "capabilities": ["text_generation", "chat"], "optional": True},
], ],
}, },
{ {
...@@ -749,9 +749,22 @@ class StudioService: ...@@ -749,9 +749,22 @@ class StudioService:
return record return record
def list_pipelines(self, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]: def list_pipelines(self, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]:
admin_rows = self._read_pipelines_json(self._admin_pipelines_path())
if self._uses_database(scope, owner_id): if self._uses_database(scope, owner_id):
return self._db().list_studio_pipelines(owner_id) user_rows = self._db().list_studio_pipelines(owner_id)
return self._read_pipelines_json(self._admin_pipelines_path()) merged: List[Dict[str, Any]] = []
seen_ids = set()
for row in user_rows:
pipeline_id = row.get("id")
if pipeline_id:
seen_ids.add(pipeline_id)
merged.append(row)
for row in admin_rows:
if row.get("id") in seen_ids:
continue
merged.append(row)
return merged
return admin_rows
def delete_pipeline(self, scope: str, owner_id: Optional[int], pipeline_id: str) -> bool: def delete_pipeline(self, scope: str, owner_id: Optional[int], pipeline_id: str) -> bool:
if self._uses_database(scope, owner_id): if self._uses_database(scope, owner_id):
...@@ -766,7 +779,9 @@ class StudioService: ...@@ -766,7 +779,9 @@ class StudioService:
def get_pipeline(self, scope: str, owner_id: Optional[int], pipeline_id: str) -> Optional[Dict[str, Any]]: def get_pipeline(self, scope: str, owner_id: Optional[int], pipeline_id: str) -> Optional[Dict[str, Any]]:
if self._uses_database(scope, owner_id): if self._uses_database(scope, owner_id):
return self._db().get_studio_pipeline(owner_id, pipeline_id) user_pipeline = self._db().get_studio_pipeline(owner_id, pipeline_id)
if user_pipeline:
return user_pipeline
rows = self.list_pipelines(scope, owner_id) rows = self.list_pipelines(scope, owner_id)
for row in rows: for row in rows:
if row.get("id") == pipeline_id: if row.get("id") == pipeline_id:
...@@ -1142,7 +1157,7 @@ class StudioService: ...@@ -1142,7 +1157,7 @@ class StudioService:
{"type": "img-edit", "label": "Image edit", "params": [["model", "text", "Model", ""], ["image", "ref", "Image ref", "{{input}}"], ["prompt", "textarea", "Prompt", "Enhance this image"]]}, {"type": "img-edit", "label": "Image edit", "params": [["model", "text", "Model", ""], ["image", "ref", "Image ref", "{{input}}"], ["prompt", "textarea", "Prompt", "Enhance this image"]]},
{"type": "img-faceswap", "label": "Face swap", "params": [["model", "text", "Model", ""], ["source_face", "ref", "Source face", "{{input}}"], ["target", "ref", "Target", "{{step0.url}}"], ["target_type", "select:image|video", "Target type", "image"]]}, {"type": "img-faceswap", "label": "Face swap", "params": [["model", "text", "Model", ""], ["source_face", "ref", "Source face", "{{input}}"], ["target", "ref", "Target", "{{step0.url}}"], ["target_type", "select:image|video", "Target type", "image"]]},
{"type": "vid-t2v", "label": "Text to video", "params": [["model", "text", "Model", ""], ["prompt", "textarea", "Prompt", "{{input}}"]]}, {"type": "vid-t2v", "label": "Text to video", "params": [["model", "text", "Model", ""], ["prompt", "textarea", "Prompt", "{{input}}"]]},
{"type": "vid-dub", "label": "Video dub", "params": [["video_model", "text", "Video model", ""], ["stt_model", "text", "STT model", ""], ["tts_model", "text", "TTS model", ""], ["video", "ref", "Video ref", "{{input}}"], ["source_lang", "text", "Source language", ""], ["target_lang", "text", "Target language", "en"], ["burn_subtitles", "checkbox", "Burn subtitles", false]]}, {"type": "vid-dub", "label": "Video dub", "params": [["video_model", "text", "Video model", ""], ["stt_model", "text", "STT model", ""], ["tts_model", "text", "TTS model", ""], ["video", "ref", "Video ref", "{{input}}"], ["source_lang", "text", "Source language", ""], ["target_lang", "text", "Target language", "en"], ["burn_subtitles", "checkbox", "Burn subtitles", False]]},
{"type": "aud-gen", "label": "Audio generate", "params": [["model", "text", "Model", ""], ["prompt", "textarea", "Prompt", "{{input}}"]]}, {"type": "aud-gen", "label": "Audio generate", "params": [["model", "text", "Model", ""], ["prompt", "textarea", "Prompt", "{{input}}"]]},
{"type": "aud-tts", "label": "Text to speech", "params": [["model", "text", "Model", ""], ["input", "textarea", "Input text", "{{input}}"], ["voice", "text", "Voice", "alloy"]]}, {"type": "aud-tts", "label": "Text to speech", "params": [["model", "text", "Model", ""], ["input", "textarea", "Input text", "{{input}}"], ["voice", "text", "Voice", "alloy"]]},
{"type": "aud-stt", "label": "Transcribe", "params": [["model", "text", "Model", ""], ["file", "ref", "Audio ref", "{{input}}"]]}, {"type": "aud-stt", "label": "Transcribe", "params": [["model", "text", "Model", ""], ["file", "ref", "Audio ref", "{{input}}"]]},
......
...@@ -341,6 +341,10 @@ setup( ...@@ -341,6 +341,10 @@ setup(
'static/aisbf-oauth2-extension.zip', 'static/aisbf-oauth2-extension.zip',
'static/i18n.js', 'static/i18n.js',
]), ]),
('share/aisbf/static/dashboard', [
'static/dashboard/studio.css',
'static/dashboard/studio.js',
]),
('share/aisbf/static/i18n', [ ('share/aisbf/static/i18n', [
'static/i18n/af.json', 'static/i18n/af.json',
'static/i18n/ar.json', 'static/i18n/ar.json',
......
This diff is collapsed.
...@@ -342,7 +342,7 @@ function escHtml(s) { ...@@ -342,7 +342,7 @@ function escHtml(s) {
if (query.length === 0) { if (query.length === 0) {
// Show all users option // Show all users option
resultsDiv.innerHTML = '<div class="user-result-item" data-user-id="" data-username=window.i18n.t('analytics.all_users') style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">All Users</div>'; resultsDiv.innerHTML = '<div class="user-result-item" data-user-id="" data-username="" style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">All Users</div>';
resultsDiv.style.display = 'block'; resultsDiv.style.display = 'block';
document.querySelector('.user-result-item').addEventListener('mouseenter', function() { document.querySelector('.user-result-item').addEventListener('mouseenter', function() {
...@@ -369,7 +369,7 @@ function escHtml(s) { ...@@ -369,7 +369,7 @@ function escHtml(s) {
// Handle focus // Handle focus
searchInput.addEventListener('focus', function() { searchInput.addEventListener('focus', function() {
if (this.value.trim().length === 0) { if (this.value.trim().length === 0) {
resultsDiv.innerHTML = '<div class="user-result-item" data-user-id="" data-username=window.i18n.t('analytics.all_users') style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">All Users</div>'; resultsDiv.innerHTML = '<div class="user-result-item" data-user-id="" data-username="" style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">All Users</div>';
resultsDiv.style.display = 'block'; resultsDiv.style.display = 'block';
document.querySelector('.user-result-item').addEventListener('mouseenter', function() { document.querySelector('.user-result-item').addEventListener('mouseenter', function() {
......
...@@ -67,6 +67,10 @@ const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }}; ...@@ -67,6 +67,10 @@ const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }};
function _providerIdFromModel(modelId) { function _providerIdFromModel(modelId) {
// model IDs in autoselect are like "provider_id/model_name" // model IDs in autoselect are like "provider_id/model_name"
if (!modelId) return null; if (!modelId) return null;
if (typeof modelId === 'object') {
modelId = modelId.model_id || modelId.id || modelId.name || '';
}
if (typeof modelId !== 'string') return null;
const parts = modelId.split('/'); const parts = modelId.split('/');
return parts.length > 1 ? parts[0] : null; return parts.length > 1 ? parts[0] : null;
} }
...@@ -141,7 +145,7 @@ async function _refreshAutoselectUsage() { ...@@ -141,7 +145,7 @@ async function _refreshAutoselectUsage() {
const pids = new Set(); const pids = new Set();
Object.values(autoselectConfig).forEach(as => { Object.values(autoselectConfig).forEach(as => {
(as.available_models || []).forEach(m => { (as.available_models || []).forEach(m => {
const pid = _providerIdFromModel(m.id || m); const pid = _providerIdFromModel(m.model_id || m.id || m);
if (_providerSupportsUsage(pid)) pids.add(pid); if (_providerSupportsUsage(pid)) pids.add(pid);
}); });
}); });
...@@ -337,6 +341,8 @@ function renderAutoselectList() { ...@@ -337,6 +341,8 @@ function renderAutoselectList() {
} }
} }
document.addEventListener('i18n:ready', renderAutoselectList);
function toggleAutoselect(key) { function toggleAutoselect(key) {
if (expandedAutoselects.has(key)) { if (expandedAutoselects.has(key)) {
expandedAutoselects.delete(key); expandedAutoselects.delete(key);
......
...@@ -335,6 +335,8 @@ function renderRotationsList() { ...@@ -335,6 +335,8 @@ function renderRotationsList() {
} }
} }
document.addEventListener('i18n:ready', renderRotationsList);
function toggleRotation(key) { function toggleRotation(key) {
if (expandedRotations.has(key)) { if (expandedRotations.has(key)) {
expandedRotations.delete(key); expandedRotations.delete(key);
......
...@@ -192,6 +192,10 @@ const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }}; ...@@ -192,6 +192,10 @@ const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }};
function _providerIdFromModel(modelId) { function _providerIdFromModel(modelId) {
if (!modelId) return null; if (!modelId) return null;
if (typeof modelId === 'object') {
modelId = modelId.model_id || modelId.id || modelId.name || '';
}
if (typeof modelId !== 'string') return null;
const parts = modelId.split('/'); const parts = modelId.split('/');
return parts.length > 1 ? parts[0] : null; return parts.length > 1 ? parts[0] : null;
} }
...@@ -347,6 +351,8 @@ function renderAutoselectList() { ...@@ -347,6 +351,8 @@ function renderAutoselectList() {
} }
} }
document.addEventListener('i18n:ready', renderAutoselectList);
function toggleAutoselect(key) { function toggleAutoselect(key) {
if (expandedAutoselects.has(key)) { if (expandedAutoselects.has(key)) {
expandedAutoselects.delete(key); expandedAutoselects.delete(key);
......
...@@ -327,6 +327,8 @@ function renderRotationsList() { ...@@ -327,6 +327,8 @@ function renderRotationsList() {
} }
} }
document.addEventListener('i18n:ready', renderRotationsList);
function toggleRotation(key) { function toggleRotation(key) {
if (expandedRotations.has(key)) { if (expandedRotations.has(key)) {
expandedRotations.delete(key); expandedRotations.delete(key);
......
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