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()}
......
...@@ -15,6 +15,18 @@ from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_ ...@@ -15,6 +15,18 @@ from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_
from aisbf.studio_services import studio_service from aisbf.studio_services import studio_service
import httpx import httpx
def _dashboard_studio_user_scope(request: Request, username: str) -> tuple[str, Optional[int]]:
auth_check = require_dashboard_auth(request)
if auth_check:
raise HTTPException(status_code=401, detail="Not authenticated")
current_username = request.session.get('username')
user_id = request.session.get('user_id')
is_config_admin = request.session.get('role') == 'admin' and user_id is None
if current_username != username and not is_config_admin:
raise HTTPException(status_code=403, detail="Forbidden")
return "user", user_id
router = APIRouter() router = APIRouter()
_config = None _config = None
_templates = None _templates = None
...@@ -30,6 +42,14 @@ async def admin_cached_models(request: Request): ...@@ -30,6 +42,14 @@ async def admin_cached_models(request: Request):
return JSONResponse(studio_service.get_cached_models()) return JSONResponse(studio_service.get_cached_models())
@router.get("/dashboard/api/studio/cached-models")
async def dashboard_studio_cached_models(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse(studio_service.get_cached_models())
@router.get("/admin/api/tokens") @router.get("/admin/api/tokens")
async def admin_tokens(request: Request): async def admin_tokens(request: Request):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -42,6 +62,18 @@ async def admin_tokens(request: Request): ...@@ -42,6 +62,18 @@ async def admin_tokens(request: Request):
return JSONResponse(db.get_user_api_tokens(user_id)) return JSONResponse(db.get_user_api_tokens(user_id))
@router.get("/dashboard/api/studio/tokens")
async def dashboard_studio_tokens(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse([])
db = DatabaseRegistry.get_config_database()
return JSONResponse(db.get_user_api_tokens(user_id))
@router.get("/admin/api/characters") @router.get("/admin/api/characters")
async def admin_characters(request: Request): async def admin_characters(request: Request):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -50,6 +82,14 @@ async def admin_characters(request: Request): ...@@ -50,6 +82,14 @@ async def admin_characters(request: Request):
return JSONResponse(studio_service.list_characters("admin", None)) return JSONResponse(studio_service.list_characters("admin", None))
@router.get("/dashboard/api/studio/characters")
async def dashboard_studio_characters(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse({"characters": studio_service.list_characters("admin", None)})
@router.get("/admin/api/characters/{name}") @router.get("/admin/api/characters/{name}")
async def admin_character_detail(request: Request, name: str): async def admin_character_detail(request: Request, name: str):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -61,6 +101,17 @@ async def admin_character_detail(request: Request, name: str): ...@@ -61,6 +101,17 @@ async def admin_character_detail(request: Request, name: str):
return JSONResponse(item) return JSONResponse(item)
@router.get("/dashboard/api/studio/characters/{name}")
async def dashboard_studio_character_detail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
item = studio_service.get_character("admin", None, name)
if not item:
raise HTTPException(status_code=404, detail="Character not found")
return JSONResponse(item)
@router.delete("/admin/api/characters/{name}") @router.delete("/admin/api/characters/{name}")
async def admin_character_delete(request: Request, name: str): async def admin_character_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -70,6 +121,15 @@ async def admin_character_delete(request: Request, name: str): ...@@ -70,6 +121,15 @@ async def admin_character_delete(request: Request, name: str):
return JSONResponse({"success": True}) return JSONResponse({"success": True})
@router.delete("/dashboard/api/studio/characters/{name}")
async def dashboard_studio_character_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
studio_service.delete_character("admin", None, name)
return JSONResponse({"success": True})
@router.get("/admin/api/characters/{name}/thumbnail") @router.get("/admin/api/characters/{name}/thumbnail")
async def admin_character_thumbnail(request: Request, name: str): async def admin_character_thumbnail(request: Request, name: str):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -81,6 +141,17 @@ async def admin_character_thumbnail(request: Request, name: str): ...@@ -81,6 +141,17 @@ async def admin_character_thumbnail(request: Request, name: str):
return Response(content=payload, media_type="image/png") return Response(content=payload, media_type="image/png")
@router.get("/dashboard/api/studio/characters/{name}/thumbnail")
async def dashboard_studio_character_thumbnail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
payload = studio_service.get_character_thumbnail_bytes("admin", None, name)
if not payload:
raise HTTPException(status_code=404, detail="Thumbnail not found")
return Response(content=payload, media_type="image/png")
@router.get("/admin/api/environments") @router.get("/admin/api/environments")
async def admin_environments(request: Request): async def admin_environments(request: Request):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -89,6 +160,14 @@ async def admin_environments(request: Request): ...@@ -89,6 +160,14 @@ async def admin_environments(request: Request):
return JSONResponse(studio_service.list_environments("admin", None)) return JSONResponse(studio_service.list_environments("admin", None))
@router.get("/dashboard/api/studio/environments")
async def dashboard_studio_environments(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse({"environments": studio_service.list_environments("admin", None)})
@router.get("/admin/api/environments/{name}") @router.get("/admin/api/environments/{name}")
async def admin_environment_detail(request: Request, name: str): async def admin_environment_detail(request: Request, name: str):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -100,6 +179,17 @@ async def admin_environment_detail(request: Request, name: str): ...@@ -100,6 +179,17 @@ async def admin_environment_detail(request: Request, name: str):
return JSONResponse(item) return JSONResponse(item)
@router.get("/dashboard/api/studio/environments/{name}")
async def dashboard_studio_environment_detail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
item = studio_service.get_environment("admin", None, name)
if not item:
raise HTTPException(status_code=404, detail="Environment not found")
return JSONResponse(item)
@router.delete("/admin/api/environments/{name}") @router.delete("/admin/api/environments/{name}")
async def admin_environment_delete(request: Request, name: str): async def admin_environment_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -109,6 +199,15 @@ async def admin_environment_delete(request: Request, name: str): ...@@ -109,6 +199,15 @@ async def admin_environment_delete(request: Request, name: str):
return JSONResponse({"success": True}) return JSONResponse({"success": True})
@router.delete("/dashboard/api/studio/environments/{name}")
async def dashboard_studio_environment_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
studio_service.delete_environment("admin", None, name)
return JSONResponse({"success": True})
@router.get("/admin/api/environments/{name}/thumbnail") @router.get("/admin/api/environments/{name}/thumbnail")
async def admin_environment_thumbnail(request: Request, name: str): async def admin_environment_thumbnail(request: Request, name: str):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -120,6 +219,17 @@ async def admin_environment_thumbnail(request: Request, name: str): ...@@ -120,6 +219,17 @@ async def admin_environment_thumbnail(request: Request, name: str):
return Response(content=payload, media_type="image/png") return Response(content=payload, media_type="image/png")
@router.get("/dashboard/api/studio/environments/{name}/thumbnail")
async def dashboard_studio_environment_thumbnail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
payload = studio_service.get_environment_thumbnail_bytes("admin", None, name)
if not payload:
raise HTTPException(status_code=404, detail="Thumbnail not found")
return Response(content=payload, media_type="image/png")
@router.get("/admin/api/voices") @router.get("/admin/api/voices")
async def admin_voices(request: Request): async def admin_voices(request: Request):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -128,6 +238,14 @@ async def admin_voices(request: Request): ...@@ -128,6 +238,14 @@ async def admin_voices(request: Request):
return JSONResponse(studio_service.list_voices("admin", None)) return JSONResponse(studio_service.list_voices("admin", None))
@router.get("/dashboard/api/studio/audio/voices")
async def dashboard_studio_voices(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse({"voices": studio_service.list_voices("admin", None)})
@router.delete("/admin/api/voices/{name}") @router.delete("/admin/api/voices/{name}")
async def admin_voice_delete(request: Request, name: str): async def admin_voice_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -136,6 +254,282 @@ async def admin_voice_delete(request: Request, name: str): ...@@ -136,6 +254,282 @@ async def admin_voice_delete(request: Request, name: str):
studio_service.delete_voice("admin", None, name) studio_service.delete_voice("admin", None, name)
return JSONResponse({"success": True}) return JSONResponse({"success": True})
@router.delete("/dashboard/api/studio/audio/voices/{name}")
async def dashboard_studio_voice_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
studio_service.delete_voice("admin", None, name)
return JSONResponse({"success": True})
@router.get("/dashboard/api/studio/pipelines/step-types")
async def dashboard_studio_pipeline_step_types(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse({"step_types": studio_service.pipeline_step_types()})
@router.get("/dashboard/api/studio/function-bindings")
async def dashboard_studio_function_bindings(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse({
"bindings": studio_service.list_function_bindings("admin", None),
"definitions": studio_service.function_binding_definitions(),
})
@router.put("/dashboard/api/studio/function-bindings/{binding_id}")
async def dashboard_studio_function_binding_save(request: Request, binding_id: str, body: dict):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
bindings = studio_service.save_function_binding("admin", None, binding_id, body.get("roles") or {})
return JSONResponse({"bindings": bindings, "binding_id": binding_id})
@router.delete("/dashboard/api/studio/function-bindings/{binding_id}")
async def dashboard_studio_function_binding_delete(request: Request, binding_id: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
bindings = studio_service.delete_function_binding("admin", None, binding_id)
return JSONResponse({"bindings": bindings, "binding_id": binding_id})
@router.get("/dashboard/api/studio/pipelines/custom")
async def dashboard_studio_pipeline_custom_list(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse({"pipelines": studio_service.list_pipelines("admin", None)})
@router.post("/dashboard/api/studio/pipelines/custom")
async def dashboard_studio_pipeline_custom_create(request: Request, body: dict):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return JSONResponse({"pipeline": studio_service.save_pipeline("admin", None, body)})
@router.put("/dashboard/api/studio/pipelines/custom/{pipeline_id}")
async def dashboard_studio_pipeline_custom_update(request: Request, pipeline_id: str, body: dict):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
payload = dict(body)
payload["id"] = pipeline_id
return JSONResponse({"pipeline": studio_service.save_pipeline("admin", None, payload)})
@router.delete("/dashboard/api/studio/pipelines/custom/{pipeline_id}")
async def dashboard_studio_pipeline_custom_delete(request: Request, pipeline_id: str):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
studio_service.delete_pipeline("admin", None, pipeline_id)
return JSONResponse({"success": True})
@router.post("/dashboard/api/studio/pipelines/custom/{pipeline_id}/run")
async def dashboard_studio_pipeline_custom_run(request: Request, pipeline_id: str, body: dict):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
pipeline = studio_service.get_pipeline("admin", None, pipeline_id)
if not pipeline:
raise HTTPException(status_code=404, detail="Pipeline not found")
payload = dict(pipeline)
payload.setdefault("seed_input", body.get("input") or "")
payload.setdefault("seed_story", body.get("story") or "")
return JSONResponse(studio_service.run_pipeline("admin", None, payload))
@router.get("/dashboard/api/studio/u/{username}/characters")
async def dashboard_user_studio_characters(request: Request, username: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse({"characters": studio_service.list_characters(scope, owner_id)})
@router.get("/dashboard/api/studio/u/{username}/characters/{name}")
async def dashboard_user_studio_character_detail(request: Request, username: str, name: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
item = studio_service.get_character(scope, owner_id, name)
if not item:
raise HTTPException(status_code=404, detail="Character not found")
return JSONResponse(item)
@router.post("/dashboard/api/studio/u/{username}/characters/extract")
async def dashboard_user_studio_character_extract(request: Request, username: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse(studio_service.save_character(scope, owner_id, body))
@router.post("/dashboard/api/studio/u/{username}/characters/generate")
async def dashboard_user_studio_character_generate(request: Request, username: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
payload = dict(body)
payload.setdefault("images", [])
return JSONResponse(studio_service.save_character(scope, owner_id, payload))
@router.get("/dashboard/api/studio/u/{username}/characters/{name}/thumbnail")
async def dashboard_user_studio_character_thumbnail(request: Request, username: str, name: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
payload = studio_service.get_character_thumbnail_bytes(scope, owner_id, name)
if not payload:
raise HTTPException(status_code=404, detail="Thumbnail not found")
return Response(content=payload, media_type="image/png")
@router.get("/dashboard/api/studio/u/{username}/environments")
async def dashboard_user_studio_environments(request: Request, username: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse({"environments": studio_service.list_environments(scope, owner_id)})
@router.get("/dashboard/api/studio/u/{username}/environments/{name}")
async def dashboard_user_studio_environment_detail(request: Request, username: str, name: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
item = studio_service.get_environment(scope, owner_id, name)
if not item:
raise HTTPException(status_code=404, detail="Environment not found")
return JSONResponse(item)
@router.post("/dashboard/api/studio/u/{username}/environments/extract")
async def dashboard_user_studio_environment_extract(request: Request, username: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse(studio_service.save_environment(scope, owner_id, body))
@router.post("/dashboard/api/studio/u/{username}/environments/generate")
async def dashboard_user_studio_environment_generate(request: Request, username: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
payload = dict(body)
payload.setdefault("images", [])
return JSONResponse(studio_service.save_environment(scope, owner_id, payload))
@router.get("/dashboard/api/studio/u/{username}/environments/{name}/thumbnail")
async def dashboard_user_studio_environment_thumbnail(request: Request, username: str, name: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
payload = studio_service.get_environment_thumbnail_bytes(scope, owner_id, name)
if not payload:
raise HTTPException(status_code=404, detail="Thumbnail not found")
return Response(content=payload, media_type="image/png")
@router.get("/dashboard/api/studio/u/{username}/audio/voices")
async def dashboard_user_studio_audio_voices(request: Request, username: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse({"voices": studio_service.list_voices(scope, owner_id)})
@router.post("/dashboard/api/studio/u/{username}/audio/voices")
async def dashboard_user_studio_audio_voice_create(request: Request, username: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
form = await request.form()
payload = {
"name": str(form.get("name") or f"voice-{int(time.time())}"),
"description": str(form.get("description") or ""),
"samples": [],
}
return JSONResponse(studio_service.save_voice(scope, owner_id, payload))
@router.post("/dashboard/api/studio/u/{username}/audio/voices/extract")
async def dashboard_user_studio_audio_voice_extract(request: Request, username: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
payload = {
"name": body.get("name") or f"voice-{int(time.time())}",
"description": body.get("description", ""),
"quote": body.get("transcript", ""),
"samples": body.get("samples", []),
}
return JSONResponse(studio_service.save_voice(scope, owner_id, payload))
@router.delete("/dashboard/api/studio/u/{username}/audio/voices/{name}")
async def dashboard_user_studio_audio_voice_delete(request: Request, username: str, name: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
studio_service.delete_voice(scope, owner_id, name)
return JSONResponse({"success": True})
@router.get("/dashboard/api/studio/u/{username}/pipelines/step-types")
async def dashboard_user_studio_pipeline_step_types(request: Request, username: str):
_dashboard_studio_user_scope(request, username)
return JSONResponse({"step_types": studio_service.pipeline_step_types()})
@router.get("/dashboard/api/studio/u/{username}/function-bindings")
async def dashboard_user_studio_function_bindings(request: Request, username: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse({
"bindings": studio_service.list_function_bindings(scope, owner_id),
"definitions": studio_service.function_binding_definitions(),
})
@router.put("/dashboard/api/studio/u/{username}/function-bindings/{binding_id}")
async def dashboard_user_studio_function_binding_save(request: Request, username: str, binding_id: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
bindings = studio_service.save_function_binding(scope, owner_id, binding_id, body.get("roles") or {})
return JSONResponse({"bindings": bindings, "binding_id": binding_id})
@router.delete("/dashboard/api/studio/u/{username}/function-bindings/{binding_id}")
async def dashboard_user_studio_function_binding_delete(request: Request, username: str, binding_id: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
bindings = studio_service.delete_function_binding(scope, owner_id, binding_id)
return JSONResponse({"bindings": bindings, "binding_id": binding_id})
@router.get("/dashboard/api/studio/u/{username}/pipelines/custom")
async def dashboard_user_studio_pipeline_custom_list(request: Request, username: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse({"pipelines": studio_service.list_pipelines(scope, owner_id)})
@router.post("/dashboard/api/studio/u/{username}/pipelines/custom")
async def dashboard_user_studio_pipeline_custom_create(request: Request, username: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
return JSONResponse({"pipeline": studio_service.save_pipeline(scope, owner_id, body)})
@router.put("/dashboard/api/studio/u/{username}/pipelines/custom/{pipeline_id}")
async def dashboard_user_studio_pipeline_custom_update(request: Request, username: str, pipeline_id: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
payload = dict(body)
payload["id"] = pipeline_id
return JSONResponse({"pipeline": studio_service.save_pipeline(scope, owner_id, payload)})
@router.delete("/dashboard/api/studio/u/{username}/pipelines/custom/{pipeline_id}")
async def dashboard_user_studio_pipeline_custom_delete(request: Request, username: str, pipeline_id: str):
scope, owner_id = _dashboard_studio_user_scope(request, username)
studio_service.delete_pipeline(scope, owner_id, pipeline_id)
return JSONResponse({"success": True})
@router.post("/dashboard/api/studio/u/{username}/pipelines/custom/{pipeline_id}/run")
async def dashboard_user_studio_pipeline_custom_run(request: Request, username: str, pipeline_id: str, body: dict):
scope, owner_id = _dashboard_studio_user_scope(request, username)
pipeline = studio_service.get_pipeline(scope, owner_id, pipeline_id)
if not pipeline:
raise HTTPException(status_code=404, detail="Pipeline not found")
payload = dict(pipeline)
payload.setdefault("seed_input", body.get("input") or "")
payload.setdefault("seed_story", body.get("story") or "")
return JSONResponse(studio_service.run_pipeline(scope, owner_id, payload))
def init(config, templates): def init(config, templates):
global _config, _templates global _config, _templates
_config = config _config = config
......
...@@ -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',
......
...@@ -99,7 +99,7 @@ try { studioBootstrap = studioBootstrapEl ? JSON.parse(studioBootstrapEl.textCon ...@@ -99,7 +99,7 @@ try { studioBootstrap = studioBootstrapEl ? JSON.parse(studioBootstrapEl.textCon
const studioScope = studioBootstrap.scope || 'admin'; const studioScope = studioBootstrap.scope || 'admin';
const studioOwnerId = studioBootstrap.owner_id || null; const studioOwnerId = studioBootstrap.owner_id || null;
const studioEntries = Array.isArray(studioBootstrap.entries) ? studioBootstrap.entries : []; const studioEntries = Array.isArray(studioBootstrap.entries) ? studioBootstrap.entries : [];
const STUDIO_API_BASE = window.__studioApiBase || '/api/v1'; const STUDIO_API_BASE = window.__studioApiBase || '/dashboard/api/studio';
const STUDIO_USERNAME = window.__studioUsername || ''; const STUDIO_USERNAME = window.__studioUsername || '';
const STUDIO_IS_GLOBAL_ADMIN = !!window.__studioIsGlobalAdmin; const STUDIO_IS_GLOBAL_ADMIN = !!window.__studioIsGlobalAdmin;
const STUDIO_SYSTEM_PROMPT = typeof window.__studioSystemPrompt === 'string' ? window.__studioSystemPrompt : ''; const STUDIO_SYSTEM_PROMPT = typeof window.__studioSystemPrompt === 'string' ? window.__studioSystemPrompt : '';
...@@ -118,11 +118,18 @@ function buildStudioUrl(path) { ...@@ -118,11 +118,18 @@ function buildStudioUrl(path) {
} }
function buildBindingApiUrl(bindingId) { function buildBindingApiUrl(bindingId) {
return buildStudioUrl(`/studio/function-bindings/${encodeURIComponent(bindingId)}`); return buildStudioUrl(`/function-bindings/${encodeURIComponent(bindingId)}`);
}
function scopeApiPath(path) {
if (!path) return path;
if (path.startsWith('/api/v1/')) return buildStudioUrl(path.slice('/api/v1'.length));
if (path.startsWith('/v1/')) return buildStudioUrl(path.slice('/v1'.length));
return buildStudioUrl(path);
} }
function buildAdminApiUrl(path) { function buildAdminApiUrl(path) {
return `/admin/api${path}`; return `${STUDIO_API_BASE}${path}`;
} }
function buildCharacterAdminUrl(name) { function buildCharacterAdminUrl(name) {
...@@ -146,15 +153,15 @@ function buildVoiceDeleteUrl(name) { ...@@ -146,15 +153,15 @@ function buildVoiceDeleteUrl(name) {
} }
function buildVoiceListUrl() { function buildVoiceListUrl() {
return STUDIO_IS_GLOBAL_ADMIN ? '/admin/api/voices' : buildStudioUrl('/audio/voices'); return STUDIO_IS_GLOBAL_ADMIN ? buildAdminApiUrl('/audio/voices') : buildStudioUrl('/audio/voices');
} }
function buildCharacterListUrl() { function buildCharacterListUrl() {
return STUDIO_IS_GLOBAL_ADMIN ? '/admin/api/characters' : buildStudioUrl('/characters'); return STUDIO_IS_GLOBAL_ADMIN ? buildAdminApiUrl('/characters') : buildStudioUrl('/characters');
} }
function buildEnvironmentListUrl() { function buildEnvironmentListUrl() {
return STUDIO_IS_GLOBAL_ADMIN ? '/admin/api/environments' : buildStudioUrl('/environments'); return STUDIO_IS_GLOBAL_ADMIN ? buildAdminApiUrl('/environments') : buildStudioUrl('/environments');
} }
function studioFetch(input, init) { function studioFetch(input, init) {
...@@ -235,7 +242,7 @@ const STUDIO_CAPABILITIES = { ...@@ -235,7 +242,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/video/dub` or `/api/u/{username}/video/dub`.', 'AISBF now proxies this Studio panel through `/api/v1/video/dub` or `/api/u/{username}/video/dub`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/video/dub` request and its multi-model payload.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/video/dub` request and its multi-model payload.'
], ],
backendPath:'/api/v1/video/dub', backendPath: scopeApiPath('/v1/video/dub'),
io:'Input: source video. Output: dubbed video with optional subtitle burn-in.' io:'Input: source video. Output: dubbed video with optional subtitle burn-in.'
}, },
'aud-gen': { 'aud-gen': {
...@@ -248,7 +255,7 @@ const STUDIO_CAPABILITIES = { ...@@ -248,7 +255,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/audio/generate` or `/api/u/{username}/audio/generate`.', 'AISBF now proxies this Studio panel through `/api/v1/audio/generate` or `/api/u/{username}/audio/generate`.',
'Actual support still depends on the selected provider/model accepting the forwarded `v1/audio/generations` request.' 'Actual support still depends on the selected provider/model accepting the forwarded `v1/audio/generations` request.'
], ],
backendPath:'/api/v1/audio/generate', backendPath: scopeApiPath('/v1/audio/generate'),
io:'Input: prompt and optional melody reference. Output: generated audio clip via proxied provider route.' io:'Input: prompt and optional melody reference. Output: generated audio clip via proxied provider route.'
}, },
'aud-music-dub': { 'aud-music-dub': {
...@@ -261,7 +268,7 @@ const STUDIO_CAPABILITIES = { ...@@ -261,7 +268,7 @@ const STUDIO_CAPABILITIES = {
'AISBF does not currently expose `/api/v1/pipelines/audio-music-dub` or a user-scoped equivalent.', 'AISBF does not currently expose `/api/v1/pipelines/audio-music-dub` or a user-scoped equivalent.',
'No local remix, stem isolation, or fallback pipeline should be implied.' 'No local remix, stem isolation, or fallback pipeline should be implied.'
], ],
backendPath:'/api/v1/pipelines/audio-music-dub', backendPath: scopeApiPath('/v1/pipelines/audio-music-dub'),
io:'Input: source song plus language goals. Output: proxied music dubbing artifacts when supported.' io:'Input: source song plus language goals. Output: proxied music dubbing artifacts when supported.'
}, },
'aud-understand': { 'aud-understand': {
...@@ -274,7 +281,7 @@ const STUDIO_CAPABILITIES = { ...@@ -274,7 +281,7 @@ const STUDIO_CAPABILITIES = {
'AISBF does not currently expose `/api/v1/pipelines/audio-understand` or a user-scoped equivalent.', 'AISBF does not currently expose `/api/v1/pipelines/audio-understand` or a user-scoped equivalent.',
'Transcript + chat remains a manual workflow, not an integrated Studio backend path.' 'Transcript + chat remains a manual workflow, not an integrated Studio backend path.'
], ],
backendPath:'/api/v1/pipelines/audio-understand', backendPath: scopeApiPath('/v1/pipelines/audio-understand'),
io:'Input: source audio or video. Output: proxied audio reasoning response when supported.' io:'Input: source audio or video. Output: proxied audio reasoning response when supported.'
}, },
'aud-stems': { 'aud-stems': {
...@@ -313,7 +320,7 @@ const STUDIO_CAPABILITIES = { ...@@ -313,7 +320,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/audio/clone` or `/api/u/{username}/audio/clone`.', 'AISBF now proxies this Studio panel through `/api/v1/audio/clone` or `/api/u/{username}/audio/clone`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/audio/clone` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/audio/clone` request.'
], ],
backendPath:'/api/v1/audio/clone', backendPath: scopeApiPath('/v1/audio/clone'),
io:'Input: text plus either a saved voice profile or reference audio/text. Output: synthesized cloned voice audio.' io:'Input: text plus either a saved voice profile or reference audio/text. Output: synthesized cloned voice audio.'
}, },
'aud-convert': { 'aud-convert': {
...@@ -326,7 +333,7 @@ const STUDIO_CAPABILITIES = { ...@@ -326,7 +333,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/audio/convert` or `/api/u/{username}/audio/convert`.', 'AISBF now proxies this Studio panel through `/api/v1/audio/convert` or `/api/u/{username}/audio/convert`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/audio/convert` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/audio/convert` request.'
], ],
backendPath:'/api/v1/audio/convert', backendPath: scopeApiPath('/v1/audio/convert'),
io:'Input: source audio plus a target voice or voice profile. Output: converted audio via proxied provider route.' io:'Input: source audio plus a target voice or voice profile. Output: converted audio via proxied provider route.'
}, },
'embed': { 'embed': {
...@@ -404,7 +411,7 @@ const STUDIO_CAPABILITIES = { ...@@ -404,7 +411,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/images/outfit` or `/api/u/{username}/images/outfit`.', 'AISBF now proxies this Studio panel through `/api/v1/images/outfit` or `/api/u/{username}/images/outfit`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/images/outfit` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/images/outfit` request.'
], ],
backendPath:'/api/v1/images/outfit', backendPath: scopeApiPath('/v1/images/outfit'),
io:'Input: source image or video plus outfit prompt. Output: transformed media via proxied provider route.' io:'Input: source image or video plus outfit prompt. Output: transformed media via proxied provider route.'
}, },
'img-depth': { 'img-depth': {
...@@ -417,7 +424,7 @@ const STUDIO_CAPABILITIES = { ...@@ -417,7 +424,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/images/depth` or `/api/u/{username}/images/depth`.', 'AISBF now proxies this Studio panel through `/api/v1/images/depth` or `/api/u/{username}/images/depth`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/images/depth` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/images/depth` request.'
], ],
backendPath:'/api/v1/images/depth', backendPath: scopeApiPath('/v1/images/depth'),
io:'Input: image. Output: depth map.' io:'Input: image. Output: depth map.'
}, },
'vid-interp': { 'vid-interp': {
...@@ -430,7 +437,7 @@ const STUDIO_CAPABILITIES = { ...@@ -430,7 +437,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/video/interpolate` or `/api/u/{username}/video/interpolate`.', 'AISBF now proxies this Studio panel through `/api/v1/video/interpolate` or `/api/u/{username}/video/interpolate`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/video/interpolate` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/video/interpolate` request.'
], ],
backendPath:'/api/v1/video/interpolate', backendPath: scopeApiPath('/v1/video/interpolate'),
io:'Input: video or keyframes. Output: interpolated video.' io:'Input: video or keyframes. Output: interpolated video.'
}, },
'vid-sub': { 'vid-sub': {
...@@ -443,7 +450,7 @@ const STUDIO_CAPABILITIES = { ...@@ -443,7 +450,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/video/subtitle` or `/api/u/{username}/video/subtitle`.', 'AISBF now proxies this Studio panel through `/api/v1/video/subtitle` or `/api/u/{username}/video/subtitle`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/video/subtitle` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/video/subtitle` request.'
], ],
backendPath:'/api/v1/video/subtitle', backendPath: scopeApiPath('/v1/video/subtitle'),
io:'Input: video. Output: subtitle text or burned video.' io:'Input: video. Output: subtitle text or burned video.'
}, },
'3d-img-to3d': { '3d-img-to3d': {
...@@ -456,7 +463,7 @@ const STUDIO_CAPABILITIES = { ...@@ -456,7 +463,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/images/to3d` or `/api/u/{username}/images/to3d`.', 'AISBF now proxies this Studio panel through `/api/v1/images/to3d` or `/api/u/{username}/images/to3d`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/images/to3d` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/images/to3d` request.'
], ],
backendPath:'/api/v1/images/to3d', backendPath: scopeApiPath('/v1/images/to3d'),
io:'Input: image. Output: stereo image or mesh artifact.' io:'Input: image. Output: stereo image or mesh artifact.'
}, },
'3d-vid-to3d': { '3d-vid-to3d': {
...@@ -469,7 +476,7 @@ const STUDIO_CAPABILITIES = { ...@@ -469,7 +476,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/video/to3d` or `/api/u/{username}/video/to3d`.', 'AISBF now proxies this Studio panel through `/api/v1/video/to3d` or `/api/u/{username}/video/to3d`.',
'Actual success depends on the selected provider/model accepting the forwarded `v1/video/to3d` request.' 'Actual success depends on the selected provider/model accepting the forwarded `v1/video/to3d` request.'
], ],
backendPath:'/api/v1/video/to3d', backendPath: scopeApiPath('/v1/video/to3d'),
io:'Input: video. Output: stereoscopic video or 3D artifact.' io:'Input: video. Output: stereoscopic video or 3D artifact.'
}, },
'3d-from3d': { '3d-from3d': {
...@@ -482,7 +489,7 @@ const STUDIO_CAPABILITIES = { ...@@ -482,7 +489,7 @@ const STUDIO_CAPABILITIES = {
'AISBF now proxies this Studio panel through `/api/v1/images/from3d`, `/api/v1/video/from3d`, and user-scoped equivalents.', 'AISBF now proxies this Studio panel through `/api/v1/images/from3d`, `/api/v1/video/from3d`, and user-scoped equivalents.',
'Actual success depends on the selected provider/model accepting the forwarded 3D render request.' 'Actual success depends on the selected provider/model accepting the forwarded 3D render request.'
], ],
backendPath:'/api/v1/images/from3d', backendPath: scopeApiPath('/v1/images/from3d'),
io:'Input: 3D asset. Output: rendered image or turntable video.' io:'Input: 3D asset. Output: rendered image or turntable video.'
} }
}; };
...@@ -1560,7 +1567,7 @@ function ensureDefaultBindingSelections() { ...@@ -1560,7 +1567,7 @@ function ensureDefaultBindingSelections() {
async function loadFunctionBindings() { async function loadFunctionBindings() {
try { try {
const res = await dashboardFetch(buildStudioUrl('/studio/function-bindings')); const res = await dashboardFetch(buildStudioUrl('/function-bindings'));
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
functionBindingDefs = Array.isArray(data.definitions) ? data.definitions : []; functionBindingDefs = Array.isArray(data.definitions) ? data.definitions : [];
...@@ -1582,7 +1589,7 @@ async function loadModels() { ...@@ -1582,7 +1589,7 @@ async function loadModels() {
async function loadLocalCapabilities() { async function loadLocalCapabilities() {
try { try {
const r = await dashboardFetch('/admin/api/cached-models'); const r = await dashboardFetch(buildAdminApiUrl('/cached-models'));
if (!r.ok) return; if (!r.ok) return;
const d = await r.json(); const d = await r.json();
_localCapSet.clear(); _localCapSet.clear();
...@@ -3633,7 +3640,7 @@ async function genSTT() { ...@@ -3633,7 +3640,7 @@ async function genSTT() {
if (val('as-lang')) fd.append('language', val('as-lang')); if (val('as-lang')) fd.append('language', val('as-lang'));
if (val('as-prompt')) fd.append('prompt', val('as-prompt')); if (val('as-prompt')) fd.append('prompt', val('as-prompt'));
try { try {
const d = await postForm('/v1/audio/transcriptions', fd); const d = await postForm('/audio/transcriptions', fd);
$('as-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left"> $('as-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left">
<pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${d.text || JSON.stringify(d,null,2)}</pre> <pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${d.text || JSON.stringify(d,null,2)}</pre>
<button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(${JSON.stringify(d.text||'')})">Copy</button> <button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(${JSON.stringify(d.text||'')})">Copy</button>
...@@ -4380,7 +4387,7 @@ profEnvLoad(); ...@@ -4380,7 +4387,7 @@ profEnvLoad();
profVoiceLoad(); profVoiceLoad();
initRequestPreviews(); initRequestPreviews();
initPipelineBuilder(); initPipelineBuilder();
dashboardFetch('/admin/api/tokens').then(r => r.json()).then(tokens => { dashboardFetch(buildAdminApiUrl('/tokens')).then(r => r.json()).then(tokens => {
if (Array.isArray(tokens) && tokens.length) apiToken = tokens[0].token; if (Array.isArray(tokens) && tokens.length) apiToken = tokens[0].token;
}).catch(() => {}); }).catch(() => {});
...@@ -4555,7 +4562,7 @@ async function pbRun() { ...@@ -4555,7 +4562,7 @@ async function pbRun() {
$('pb-prog').textContent = 'Running…'; $('pb-prog').textContent = 'Running…';
$('pb-out').innerHTML = ''; $('pb-out').innerHTML = '';
try { try {
const d = await post('/v1/pipelines/run', {...def, input: val('pb-input')}); const d = await post('/pipelines/run', {...def, input: val('pb-input')});
_renderPipelineResult('pb-out', 'pb-prog', d); _renderPipelineResult('pb-out', 'pb-prog', d);
} catch(e) { $('pb-prog').textContent = 'Error: '+e.message; } } catch(e) { $('pb-prog').textContent = 'Error: '+e.message; }
} }
...@@ -4568,7 +4575,7 @@ async function runCustomPipeline(id) { ...@@ -4568,7 +4575,7 @@ async function runCustomPipeline(id) {
if (out) out.innerHTML = ''; if (out) out.innerHTML = '';
try { try {
const input = val(`cpr-input-${id}`) || ''; const input = val(`cpr-input-${id}`) || '';
const d = await post(`/v1/pipelines/custom/${id}/run`, {input}); const d = await post(`/pipelines/custom/${id}/run`, {input});
_renderPipelineResult(`cpr-out-${id}`, `cpr-prog-${id}`, d); _renderPipelineResult(`cpr-out-${id}`, `cpr-prog-${id}`, d);
} catch(e) { if (prog) prog.textContent = 'Error: '+e.message; } } catch(e) { if (prog) prog.textContent = 'Error: '+e.message; }
} }
......
...@@ -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