Minimal template fixes

parent 6130a6db
......@@ -18,5 +18,6 @@ recursive-include templates *.css
recursive-include templates *.js
recursive-include static *.zip
recursive-include static *.js
recursive-include static *.css
recursive-include static/i18n *.json
recursive-include static/extension *.js *.json *.html *.md *.png *.svg
......@@ -962,6 +962,8 @@ class Analytics:
Returns:
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
# context_dimensions is used only for metadata (context_size, condense settings)
with self.db._get_connection() as conn:
......@@ -975,12 +977,10 @@ class Analytics:
'''
params = []
if from_datetime:
query += f' AND timestamp >= {placeholder}'
params.append(self._format_timestamp(from_datetime))
if to_datetime:
query += f' AND timestamp <= {placeholder}'
params.append(self._format_timestamp(to_datetime))
query += f' AND timestamp >= {placeholder}'
params.append(self._format_timestamp(start))
query += f' AND timestamp <= {placeholder}'
params.append(self._format_timestamp(end))
if user_filter == -1:
query += ' AND user_id IS NULL'
elif user_filter is not None:
......@@ -1003,7 +1003,15 @@ class Analytics:
query += ' ORDER BY provider_id, model_name'
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)
raw_dims = self.db.get_all_context_dimensions(user_filter=user_filter)
......@@ -1011,7 +1019,9 @@ class Analytics:
# Build context_dims from active token_usage combinations, enriched with context metadata
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), {})
context_dims.append({
'provider_id': pid,
......@@ -1053,8 +1063,8 @@ class Analytics:
continue
combo_stats = next((item for item in self.get_all_providers_stats(
from_datetime,
to_datetime,
start,
end,
user_filter=user_filter,
provider_filter=provider_id,
model_filter=model_name,
......@@ -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)
provider_stats = self.get_all_providers_stats(
from_datetime,
to_datetime,
start,
end,
user_filter=user_filter,
provider_filter=provider_filter,
model_filter=model_filter,
......@@ -1271,9 +1281,6 @@ class Analytics:
if not tier_info:
continue
if tier_info.get('source') == 'default':
continue
cached_usage = None
try:
cached_row = self.db.get_provider_usage(None if user_filter == -1 else user_filter, provider_id)
......@@ -1290,6 +1297,10 @@ class Analytics:
if free_limit <= 0:
continue
premium_price = float(tier_info.get('premium_monthly_cost', 0) or 0)
if premium_price <= 0:
continue
if limit_type == 'tokens':
usage_amount = usage.get('tokens_used', 0) or 0
else:
......@@ -1302,9 +1313,11 @@ class Analytics:
if extra_free_tiers <= 0:
continue
if tier_info.get('source') == 'default' and usage_amount > free_limit * 10:
continue
saved_tokens = usage['tokens_used'] * extra_free_tiers
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
provider_equivalents.append({
'provider_id': provider_id,
......
......@@ -6,7 +6,7 @@ import time
import asyncio
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 aisbf.coderai_broker import broker
......@@ -17,6 +17,26 @@ router = APIRouter()
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):
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())}"
......@@ -26,21 +46,14 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
if not valid:
await websocket.close(code=1008, reason=error or "registration rejected")
return
await websocket.accept()
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})
await websocket.accept()
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})
try:
await websocket.send_text(json.dumps({
"v": 1,
"event": "registered",
"session_id": session.session_id,
"provider_id": session.provider_id,
"client_id": session.client_id,
"username": username,
"scope_name": expected_scope,
"accepted": True,
}))
payload = _coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope)
payload["session_id"] = session.session_id
await websocket.send_text(json.dumps(payload))
while True:
try:
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):
"request_id": message.get("request_id"),
"status": "ok",
"payload": {
"accepted": True,
**_coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope),
"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
......@@ -126,6 +133,30 @@ async def coderai_broker_websocket_user(websocket: WebSocket, username: str):
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")
async def coderai_broker_sessions():
return {"sessions": await broker.list_sessions()}
......
......@@ -15,6 +15,18 @@ from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_
from aisbf.studio_services import studio_service
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()
_config = None
_templates = None
......@@ -30,6 +42,14 @@ async def admin_cached_models(request: Request):
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")
async def admin_tokens(request: Request):
auth_check = require_dashboard_auth(request)
......@@ -42,6 +62,18 @@ async def admin_tokens(request: Request):
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")
async def admin_characters(request: Request):
auth_check = require_dashboard_auth(request)
......@@ -50,6 +82,14 @@ async def admin_characters(request: Request):
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}")
async def admin_character_detail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
......@@ -61,6 +101,17 @@ async def admin_character_detail(request: Request, name: str):
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}")
async def admin_character_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request)
......@@ -70,6 +121,15 @@ async def admin_character_delete(request: Request, name: str):
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")
async def admin_character_thumbnail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
......@@ -81,6 +141,17 @@ async def admin_character_thumbnail(request: Request, name: str):
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")
async def admin_environments(request: Request):
auth_check = require_dashboard_auth(request)
......@@ -89,6 +160,14 @@ async def admin_environments(request: Request):
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}")
async def admin_environment_detail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
......@@ -100,6 +179,17 @@ async def admin_environment_detail(request: Request, name: str):
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}")
async def admin_environment_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request)
......@@ -109,6 +199,15 @@ async def admin_environment_delete(request: Request, name: str):
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")
async def admin_environment_thumbnail(request: Request, name: str):
auth_check = require_dashboard_auth(request)
......@@ -120,6 +219,17 @@ async def admin_environment_thumbnail(request: Request, name: str):
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")
async def admin_voices(request: Request):
auth_check = require_dashboard_auth(request)
......@@ -128,6 +238,14 @@ async def admin_voices(request: Request):
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}")
async def admin_voice_delete(request: Request, name: str):
auth_check = require_dashboard_auth(request)
......@@ -136,6 +254,282 @@ async def admin_voice_delete(request: Request, name: str):
studio_service.delete_voice("admin", None, name)
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):
global _config, _templates
_config = config
......
......@@ -595,7 +595,7 @@ async def dashboard_studio(request: Request):
"session": request.session,
"__version__": __version__,
"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_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)),
......
......@@ -47,7 +47,7 @@ class StudioService:
"category": "chat",
"endpoint": "/chat/completions",
"roles": [
{"key": "model", "label": "Chat model", "capabilities": ["text_generation"]},
{"key": "model", "label": "Chat model", "capabilities": ["text_generation", "chat"]},
],
},
{
......@@ -350,7 +350,7 @@ class StudioService:
"endpoint": "/pipelines/audio-understand",
"roles": [
{"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:
return record
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):
return self._db().list_studio_pipelines(owner_id)
return self._read_pipelines_json(self._admin_pipelines_path())
user_rows = self._db().list_studio_pipelines(owner_id)
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:
if self._uses_database(scope, owner_id):
......@@ -766,7 +779,9 @@ class StudioService:
def get_pipeline(self, scope: str, owner_id: Optional[int], pipeline_id: str) -> Optional[Dict[str, Any]]:
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)
for row in rows:
if row.get("id") == pipeline_id:
......@@ -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-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-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-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}}"]]},
......
......@@ -341,6 +341,10 @@ setup(
'static/aisbf-oauth2-extension.zip',
'static/i18n.js',
]),
('share/aisbf/static/dashboard', [
'static/dashboard/studio.css',
'static/dashboard/studio.js',
]),
('share/aisbf/static/i18n', [
'static/i18n/af.json',
'static/i18n/ar.json',
......
......@@ -99,7 +99,7 @@ try { studioBootstrap = studioBootstrapEl ? JSON.parse(studioBootstrapEl.textCon
const studioScope = studioBootstrap.scope || 'admin';
const studioOwnerId = studioBootstrap.owner_id || null;
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_IS_GLOBAL_ADMIN = !!window.__studioIsGlobalAdmin;
const STUDIO_SYSTEM_PROMPT = typeof window.__studioSystemPrompt === 'string' ? window.__studioSystemPrompt : '';
......@@ -118,11 +118,18 @@ function buildStudioUrl(path) {
}
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) {
return `/admin/api${path}`;
return `${STUDIO_API_BASE}${path}`;
}
function buildCharacterAdminUrl(name) {
......@@ -146,15 +153,15 @@ function buildVoiceDeleteUrl(name) {
}
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() {
return STUDIO_IS_GLOBAL_ADMIN ? '/admin/api/characters' : buildStudioUrl('/characters');
return STUDIO_IS_GLOBAL_ADMIN ? buildAdminApiUrl('/characters') : buildStudioUrl('/characters');
}
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) {
......@@ -235,7 +242,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
backendPath:'/api/v1/video/dub',
backendPath: scopeApiPath('/v1/video/dub'),
io:'Input: source video. Output: dubbed video with optional subtitle burn-in.'
},
'aud-gen': {
......@@ -248,7 +255,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
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.'
},
'aud-music-dub': {
......@@ -261,7 +268,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
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.'
},
'aud-understand': {
......@@ -274,7 +281,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
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.'
},
'aud-stems': {
......@@ -313,7 +320,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
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.'
},
'aud-convert': {
......@@ -326,7 +333,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
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.'
},
'embed': {
......@@ -404,7 +411,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
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.'
},
'img-depth': {
......@@ -417,7 +424,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
backendPath:'/api/v1/images/depth',
backendPath: scopeApiPath('/v1/images/depth'),
io:'Input: image. Output: depth map.'
},
'vid-interp': {
......@@ -430,7 +437,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
backendPath:'/api/v1/video/interpolate',
backendPath: scopeApiPath('/v1/video/interpolate'),
io:'Input: video or keyframes. Output: interpolated video.'
},
'vid-sub': {
......@@ -443,7 +450,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
backendPath:'/api/v1/video/subtitle',
backendPath: scopeApiPath('/v1/video/subtitle'),
io:'Input: video. Output: subtitle text or burned video.'
},
'3d-img-to3d': {
......@@ -456,7 +463,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
backendPath:'/api/v1/images/to3d',
backendPath: scopeApiPath('/v1/images/to3d'),
io:'Input: image. Output: stereo image or mesh artifact.'
},
'3d-vid-to3d': {
......@@ -469,7 +476,7 @@ const STUDIO_CAPABILITIES = {
'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.'
],
backendPath:'/api/v1/video/to3d',
backendPath: scopeApiPath('/v1/video/to3d'),
io:'Input: video. Output: stereoscopic video or 3D artifact.'
},
'3d-from3d': {
......@@ -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.',
'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.'
}
};
......@@ -1560,7 +1567,7 @@ function ensureDefaultBindingSelections() {
async function loadFunctionBindings() {
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());
const data = await res.json();
functionBindingDefs = Array.isArray(data.definitions) ? data.definitions : [];
......@@ -1582,7 +1589,7 @@ async function loadModels() {
async function loadLocalCapabilities() {
try {
const r = await dashboardFetch('/admin/api/cached-models');
const r = await dashboardFetch(buildAdminApiUrl('/cached-models'));
if (!r.ok) return;
const d = await r.json();
_localCapSet.clear();
......@@ -3633,7 +3640,7 @@ async function genSTT() {
if (val('as-lang')) fd.append('language', val('as-lang'));
if (val('as-prompt')) fd.append('prompt', val('as-prompt'));
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">
<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>
......@@ -4380,7 +4387,7 @@ profEnvLoad();
profVoiceLoad();
initRequestPreviews();
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;
}).catch(() => {});
......@@ -4555,7 +4562,7 @@ async function pbRun() {
$('pb-prog').textContent = 'Running…';
$('pb-out').innerHTML = '';
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);
} catch(e) { $('pb-prog').textContent = 'Error: '+e.message; }
}
......@@ -4568,7 +4575,7 @@ async function runCustomPipeline(id) {
if (out) out.innerHTML = '';
try {
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);
} catch(e) { if (prog) prog.textContent = 'Error: '+e.message; }
}
......
......@@ -342,7 +342,7 @@ function escHtml(s) {
if (query.length === 0) {
// 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';
document.querySelector('.user-result-item').addEventListener('mouseenter', function() {
......@@ -369,7 +369,7 @@ function escHtml(s) {
// Handle focus
searchInput.addEventListener('focus', function() {
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';
document.querySelector('.user-result-item').addEventListener('mouseenter', function() {
......
......@@ -67,6 +67,10 @@ const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }};
function _providerIdFromModel(modelId) {
// model IDs in autoselect are like "provider_id/model_name"
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('/');
return parts.length > 1 ? parts[0] : null;
}
......@@ -141,7 +145,7 @@ async function _refreshAutoselectUsage() {
const pids = new Set();
Object.values(autoselectConfig).forEach(as => {
(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);
});
});
......@@ -337,6 +341,8 @@ function renderAutoselectList() {
}
}
document.addEventListener('i18n:ready', renderAutoselectList);
function toggleAutoselect(key) {
if (expandedAutoselects.has(key)) {
expandedAutoselects.delete(key);
......
......@@ -335,6 +335,8 @@ function renderRotationsList() {
}
}
document.addEventListener('i18n:ready', renderRotationsList);
function toggleRotation(key) {
if (expandedRotations.has(key)) {
expandedRotations.delete(key);
......
......@@ -192,6 +192,10 @@ const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }};
function _providerIdFromModel(modelId) {
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('/');
return parts.length > 1 ? parts[0] : null;
}
......@@ -347,6 +351,8 @@ function renderAutoselectList() {
}
}
document.addEventListener('i18n:ready', renderAutoselectList);
function toggleAutoselect(key) {
if (expandedAutoselects.has(key)) {
expandedAutoselects.delete(key);
......
......@@ -327,6 +327,8 @@ function renderRotationsList() {
}
}
document.addEventListener('i18n:ready', renderRotationsList);
function toggleRotation(key) {
if (expandedRotations.has(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