feat: integrate studio bindings and executable pipelines

parent 7b4a5b2d
......@@ -149,6 +149,28 @@ Shared Studio integration helpers:
- Normalizes dashboard-visible provider, rotation, and autoselect resources into a unified Studio catalog
- Infers and merges Studio capability metadata for autodetected and manually configured models
- Supports admin/global and user-owned dashboard catalog scopes
- Carries effective Studio adapter metadata for provider models so Studio workflow proxying can choose provider-aware payload shaping
### aisbf/studio_adapters.py
Studio adapter inference helpers:
- Defines manual/automatic Studio adapter choices for provider models
- Defines provider-specific Studio adapter profile choices layered above adapter family inference
- Infers the best adapter from provider type and model capabilities when no manual override is set
- Infers the best adapter profile from the configured provider identity, endpoint, and model metadata when no manual override is set
- Supplies the effective adapter used by Studio workflow payload normalization
- Contains provider-family-aware Studio payload transformation helpers used by the generic proxy path before requests are forwarded upstream
- Contains explicit profile-specific request shaping for OpenAI-style, OpenRouter-style, Gemini-style, Anthropic-style, and Ollama-style Studio media workflows
- Includes provider-id/endpoint specific profiles such as Kilo/OpenRouter-style, GitHub Models-style, and Azure OpenAI-style request shaping
### aisbf/studio_services.py
Studio persistence and runtime helpers for dashboard Studio:
- Stores characters, environments, voices, archive items, and custom pipelines per Studio scope
- Uses file-backed storage for the config admin scope under `~/.aisbf/studio/`
- Uses database-backed storage for user-owned Studio assets and pipelines
- Stores admin custom pipelines in `~/.aisbf/pipelines.json` as a plain JSON array
- Stores persistent Studio functionality-to-model bindings in `~/.aisbf/studio_bindings.json` for config admin scope and in the user prompt override key `studio_function_bindings` for user scope
- Loads Studio chat system prompt from `STUDIO_SYSTEM.md`, using file-backed admin defaults and database-backed user overrides
- Normalizes Studio profile payloads and pipeline definitions for both admin and user scopes
### main.py
FastAPI application:
......@@ -959,6 +981,17 @@ This AI.PROMPT file is automatically updated when significant changes are made t
- Provider model save and autodetect flows now persist normalized Studio capability metadata for manual and inferred models
- Studio catalog entries can expose partial aggregate capabilities so incomplete metadata stays visible instead of disappearing
**2026-05-11 - Dashboard Studio Runtime and Persistence Expansion**
- Replaced the partial dashboard Studio shell with the original full Studio UI adapted to dashboard templates, CSS, and proxy-aware routing
- Added scope-aware Studio API bases so the config admin uses `/api/v1` while user-scoped sessions use `/api/u/{username}`
- Restricted global Studio scope to the config admin defined by `aisbf.json`; database-backed admins remain user-scoped
- Added `aisbf/studio_services.py` for Studio asset and pipeline persistence across admin/file and user/database scopes
- Added `studio_assets` and `studio_pipelines` database tables for user-owned Studio storage, created through normal startup migrations
- Kept global config admin characters, environments, voices, archive items, and pipelines file-backed under `~/.aisbf/studio/`
- Saved admin custom pipelines in `~/.aisbf/pipelines.json` and user custom pipelines in the database
- Added `/api/v1/...` and `/api/u/{username}/...` Studio aliases for profile, archive, pipeline, and proxied advanced media routes
- Dashboard Studio advanced media tools must proxy to provider-facing `v1/...` endpoints; no local ffmpeg/OpenCV/InsightFace/Real-ESRGAN processing is assumed
**2026-03-22 - Configuration Refactoring**
- Centralized API key storage in providers.json
- API keys are now stored only in provider definitions, not in rotation/autoselect configs
......
......@@ -5,3 +5,4 @@
- [ ] Add support for mempalace
- [ ] Add support for caveman mode
- [ ] Integrate github larsderidder context-lens project
- [ ] Integrate https://github.com/PromptSail/prompt_sail
......@@ -6,6 +6,7 @@ from pathlib import Path
from aisbf.models import ChatCompletionRequest
from aisbf.database import DatabaseRegistry
from aisbf.app.model_cache import get_provider_models, _refresh_provider_usage_if_stale, _background_tasks
from aisbf.studio_services import studio_service
router = APIRouter()
_config = None
......@@ -28,6 +29,36 @@ def parse_provider_from_model(model: str) -> tuple[str, str]:
return parts[0], parts[1]
return None, model
def _api_v1_path(path: str) -> str:
return f"/api/v1{path}"
def _normalize_studio_proxy_body(endpoint_path: str, body: dict) -> dict:
normalized = dict(body or {})
def prefer_model(*keys):
for key in keys:
value = normalized.get(key)
if isinstance(value, str) and value.strip():
normalized['model'] = value.strip()
return
if endpoint_path == "v1/video/dub":
prefer_model('video_model', 'stt_model', 'tts_model', 'model')
elif endpoint_path == "v1/audio/clone":
prefer_model('model', 'tts_model')
elif endpoint_path == "v1/audio/convert":
prefer_model('model', 'audio_model', 'tts_model', 'stt_model')
elif endpoint_path in {"v1/audio/split", "v1/audio/denoise"}:
prefer_model('model', 'audio_model')
elif endpoint_path in {"v1/images/faceswap", "v1/images/outfit"}:
prefer_model('model', 'image_model', 'video_model')
elif endpoint_path in {"v1/images/to3d", "v1/images/from3d", "v1/video/to3d", "v1/video/from3d", "v1/3d/generate"}:
prefer_model('model', 'render_model', 'image_model', 'video_model')
return normalized
@router.get("/")
async def root():
return {
......@@ -424,6 +455,7 @@ async def _generic_proxy(request: Request, body: dict, endpoint_path: str, metho
"""Resolve provider from body['model'] and forward to provider endpoint."""
user_id = getattr(request.state, 'user_id', None)
handler = _get_user_handler('request', user_id)
body = _normalize_studio_proxy_body(endpoint_path, body)
provider_id, actual_model = _resolve_provider(body.get('model', ''), user_id=user_id, handler=handler)
body['model'] = actual_model
return await handler.handle_generic_proxy(request, provider_id, endpoint_path, body, method=method)
......@@ -463,6 +495,10 @@ async def v1_image_detect(request: Request, body: dict):
async def v1_image_segment(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/segment")
@router.post("/api/v1/images/depth")
async def v1_image_depth(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/depth")
@router.post("/api/v1/images/restore")
async def v1_image_restore(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/restore")
......@@ -479,6 +515,30 @@ async def v1_image_style_transfer(request: Request, body: dict):
async def v1_image_remove_bg(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/remove-bg")
@router.post("/api/v1/images/faceswap")
async def v1_image_faceswap(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/faceswap")
@router.post("/api/v1/images/deblur")
async def v1_image_deblur(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/deblur")
@router.post("/api/v1/images/unpixelate")
async def v1_image_unpixelate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/unpixelate")
@router.post("/api/v1/images/outfit")
async def v1_image_outfit(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/outfit")
@router.post("/api/v1/images/to3d")
async def v1_image_to3d(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/to3d")
@router.post("/api/v1/images/from3d")
async def v1_image_from3d(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/images/from3d")
# ── Video ─────────────────────────────────────────────────────────────────────
......@@ -506,6 +566,26 @@ async def v1_video_transcriptions(request: Request, body: dict):
async def v1_video_upscale(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/upscale")
@router.post("/api/v1/video/interpolate")
async def v1_video_interpolate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/interpolate")
@router.post("/api/v1/video/subtitle")
async def v1_video_subtitle(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/subtitle")
@router.post("/api/v1/video/dub")
async def v1_video_dub(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/dub")
@router.post("/api/v1/video/to3d")
async def v1_video_to3d(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/to3d")
@router.post("/api/v1/video/from3d")
async def v1_video_from3d(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/video/from3d")
# ── Audio ─────────────────────────────────────────────────────────────────────
......@@ -513,6 +593,10 @@ async def v1_video_upscale(request: Request, body: dict):
async def v1_audio_generations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/generations")
@router.post("/api/v1/audio/generate")
async def v1_audio_generate_alias(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/generations")
@router.post("/api/v1/audio/translations")
async def v1_audio_translations(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/translations")
......@@ -541,6 +625,266 @@ async def v1_audio_diarize(request: Request, body: dict):
async def v1_audio_translate(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/translate")
@router.post("/api/v1/audio/stems")
async def v1_audio_stems(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/split")
@router.post("/api/v1/audio/cleanup")
async def v1_audio_cleanup(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/denoise")
@router.post("/api/v1/audio/clone")
async def v1_audio_clone(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/clone")
@router.post("/api/v1/audio/convert")
async def v1_audio_convert(request: Request, body: dict):
return await _generic_proxy(request, body, "v1/audio/convert")
def _studio_scope(request: Request) -> tuple[str, Optional[int]]:
user_id = getattr(request.state, 'user_id', None)
return ("user", user_id) if user_id else ("admin", None)
@router.get("/v1/audio/progress")
@router.get("/api/v1/audio/progress")
async def studio_audio_progress():
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0}
@router.get("/v1/video/progress")
@router.get("/api/v1/video/progress")
async def studio_video_progress():
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0}
@router.get("/v1/images/progress")
@router.get("/api/v1/images/progress")
async def studio_images_progress():
return {"active": False, "current": 0, "total": 0, "pct": 0, "elapsed": 0}
@router.get("/v1/archive")
@router.get("/api/v1/archive")
async def studio_archive(request: Request):
scope, owner_id = _studio_scope(request)
return {"files": studio_service.list_archive(scope, owner_id)}
@router.delete("/v1/archive/{filename}")
@router.delete("/api/v1/archive/{filename}")
async def studio_archive_delete(request: Request, filename: str):
scope, owner_id = _studio_scope(request)
archive_dir = studio_service._scope_dir(studio_service.archive_dir, scope, owner_id)
target = archive_dir / filename
if target.exists():
target.unlink()
return {"success": True}
@router.get("/v1/characters")
@router.get("/api/v1/characters")
async def studio_characters(request: Request):
scope, owner_id = _studio_scope(request)
return {"characters": studio_service.list_characters(scope, owner_id)}
@router.get("/v1/characters/{name}")
@router.get("/api/v1/characters/{name}")
async def studio_character_detail(request: Request, name: str):
scope, owner_id = _studio_scope(request)
item = studio_service.get_character(scope, owner_id, name)
if not item:
raise HTTPException(status_code=404, detail="Character not found")
return item
@router.post("/v1/characters/extract")
@router.post("/api/v1/characters/extract")
async def studio_character_extract(request: Request, body: dict):
scope, owner_id = _studio_scope(request)
return studio_service.save_character(scope, owner_id, body)
@router.post("/v1/characters/generate")
@router.post("/api/v1/characters/generate")
async def studio_character_generate(request: Request, body: dict):
scope, owner_id = _studio_scope(request)
payload = dict(body)
payload.setdefault("images", [])
return studio_service.save_character(scope, owner_id, payload)
@router.get("/v1/characters/{name}/thumbnail")
@router.get("/api/v1/characters/{name}/thumbnail")
async def studio_character_thumbnail(request: Request, name: str):
scope, owner_id = _studio_scope(request)
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("/v1/environments")
@router.get("/api/v1/environments")
async def studio_environments(request: Request):
scope, owner_id = _studio_scope(request)
return {"environments": studio_service.list_environments(scope, owner_id)}
@router.post("/v1/environments/extract")
@router.post("/api/v1/environments/extract")
async def studio_environment_extract(request: Request, body: dict):
scope, owner_id = _studio_scope(request)
return studio_service.save_environment(scope, owner_id, body)
@router.post("/v1/environments/generate")
@router.post("/api/v1/environments/generate")
async def studio_environment_generate(request: Request, body: dict):
scope, owner_id = _studio_scope(request)
payload = dict(body)
payload.setdefault("images", [])
return studio_service.save_environment(scope, owner_id, payload)
@router.get("/v1/environments/{name}")
@router.get("/api/v1/environments/{name}")
async def studio_environment_detail(request: Request, name: str):
scope, owner_id = _studio_scope(request)
item = studio_service.get_environment(scope, owner_id, name)
if not item:
raise HTTPException(status_code=404, detail="Environment not found")
return item
@router.get("/v1/environments/{name}/thumbnail")
@router.get("/api/v1/environments/{name}/thumbnail")
async def studio_environment_thumbnail(request: Request, name: str):
scope, owner_id = _studio_scope(request)
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("/v1/audio/voices")
@router.get("/api/v1/audio/voices")
async def studio_audio_voices(request: Request):
scope, owner_id = _studio_scope(request)
return {"voices": studio_service.list_voices(scope, owner_id)}
@router.post("/v1/audio/voices")
@router.post("/api/v1/audio/voices")
async def studio_audio_voice_create(request: Request):
form = await request.form()
scope, owner_id = _studio_scope(request)
payload = {
"name": str(form.get("name") or f"voice-{int(time.time())}"),
"description": str(form.get("description") or ""),
"samples": [],
}
return studio_service.save_voice(scope, owner_id, payload)
@router.post("/v1/audio/voices/extract")
@router.post("/api/v1/audio/voices/extract")
async def studio_audio_voice_extract(request: Request, body: dict):
scope, owner_id = _studio_scope(request)
payload = {
"name": body.get("name") or f"voice-{int(time.time())}",
"description": body.get("description", ""),
"quote": body.get("transcript", ""),
"samples": body.get("samples", []),
}
return studio_service.save_voice(scope, owner_id, payload)
@router.get("/v1/pipelines/step-types")
@router.get("/api/v1/pipelines/step-types")
async def studio_pipeline_step_types():
return {"step_types": studio_service.pipeline_step_types()}
@router.get("/v1/studio/function-bindings")
@router.get("/api/v1/studio/function-bindings")
async def studio_function_bindings(request: Request):
scope, owner_id = _studio_scope(request)
return {
"bindings": studio_service.list_function_bindings(scope, owner_id),
"definitions": studio_service.function_binding_definitions(),
}
@router.put("/v1/studio/function-bindings/{binding_id}")
@router.put("/api/v1/studio/function-bindings/{binding_id}")
async def studio_function_binding_save(request: Request, binding_id: str, body: dict):
scope, owner_id = _studio_scope(request)
bindings = studio_service.save_function_binding(scope, owner_id, binding_id, body.get("roles") or {})
return {"bindings": bindings, "binding_id": binding_id}
@router.delete("/v1/studio/function-bindings/{binding_id}")
@router.delete("/api/v1/studio/function-bindings/{binding_id}")
async def studio_function_binding_delete(request: Request, binding_id: str):
scope, owner_id = _studio_scope(request)
bindings = studio_service.delete_function_binding(scope, owner_id, binding_id)
return {"bindings": bindings, "binding_id": binding_id}
@router.get("/v1/pipelines/custom")
@router.get("/api/v1/pipelines/custom")
async def studio_pipeline_custom_list(request: Request):
scope, owner_id = _studio_scope(request)
return {"pipelines": studio_service.list_pipelines(scope, owner_id)}
@router.post("/v1/pipelines/custom")
@router.post("/api/v1/pipelines/custom")
async def studio_pipeline_custom_create(request: Request, body: dict):
scope, owner_id = _studio_scope(request)
return {"pipeline": studio_service.save_pipeline(scope, owner_id, body)}
@router.put("/v1/pipelines/custom/{pipeline_id}")
@router.put("/api/v1/pipelines/custom/{pipeline_id}")
async def studio_pipeline_custom_update(request: Request, pipeline_id: str, body: dict):
scope, owner_id = _studio_scope(request)
payload = dict(body)
payload["id"] = pipeline_id
return {"pipeline": studio_service.save_pipeline(scope, owner_id, payload)}
@router.delete("/v1/pipelines/custom/{pipeline_id}")
@router.delete("/api/v1/pipelines/custom/{pipeline_id}")
async def studio_pipeline_custom_delete(request: Request, pipeline_id: str):
scope, owner_id = _studio_scope(request)
studio_service.delete_pipeline(scope, owner_id, pipeline_id)
return {"success": True}
@router.post("/v1/pipelines/custom/{pipeline_id}/run")
@router.post("/api/v1/pipelines/custom/{pipeline_id}/run")
async def studio_pipeline_custom_run(request: Request, pipeline_id: str, body: dict):
scope, owner_id = _studio_scope(request)
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.update(body or {})
payload["_api_base"] = "/api/v1"
return studio_service.run_pipeline(payload)
@router.post("/v1/pipelines/run")
@router.post("/api/v1/pipelines/run")
async def studio_pipeline_run(body: dict):
payload = dict(body or {})
payload["_api_base"] = "/api/v1"
return studio_service.run_pipeline(payload)
# ── Text / NLP ────────────────────────────────────────────────────────────────
......
......@@ -12,6 +12,7 @@ from aisbf.app.startup import (_reload_global_config, _apply_condense_defaults_p
_apply_condense_defaults_rotation, _providers_json_path, _rotations_json_path,
_autoselect_json_path, get_aisbf_config_path)
from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_api_admin, require_admin
from aisbf.studio_services import studio_service
import httpx
router = APIRouter()
......@@ -20,6 +21,121 @@ _templates = None
logger = logging.getLogger(__name__)
@router.get("/admin/api/cached-models")
async def admin_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)
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)
if auth_check:
return auth_check
return JSONResponse(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)
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)
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)
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)
if auth_check:
return auth_check
return JSONResponse(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)
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)
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)
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)
if auth_check:
return auth_check
return JSONResponse(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)
if auth_check:
return auth_check
studio_service.delete_voice("admin", None, name)
return JSONResponse({"success": True})
def init(config, templates):
global _config, _templates
_config = config
......
......@@ -150,6 +150,7 @@ async def dashboard_prompts(request: Request):
{'key': 'condensation_conversational', 'name': 'Condensation - Conversational', 'filename': 'condensation_conversational.md'},
{'key': 'condensation_semantic', 'name': 'Condensation - Semantic', 'filename': 'condensation_semantic.md'},
{'key': 'autoselect', 'name': 'Autoselect - Model Selection', 'filename': 'autoselect.md'},
{'key': 'studio_system', 'name': 'Studio - System Prompt', 'filename': 'STUDIO_SYSTEM.md'},
]
prompts_data = []
......@@ -239,6 +240,7 @@ async def dashboard_prompts_save(request: Request, prompt_key: str = Form(...),
'condensation_conversational': 'condensation_conversational.md',
'condensation_semantic': 'condensation_semantic.md',
'autoselect': 'autoselect.md',
'studio_system': 'STUDIO_SYSTEM.md',
}
if prompt_key not in prompt_map:
......
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from typing import Optional
import logging, time
from aisbf.models import ChatCompletionRequest
from aisbf.database import DatabaseRegistry
from aisbf.app.model_cache import get_provider_models
from aisbf.studio_services import studio_service
router = APIRouter()
_config = None
......@@ -23,6 +24,32 @@ def parse_provider_from_model(model: str) -> tuple[str, str]:
return parts[0], parts[1]
return None, model
def _normalize_studio_proxy_body(endpoint_path: str, body: dict) -> dict:
normalized = dict(body or {})
def prefer_model(*keys):
for key in keys:
value = normalized.get(key)
if isinstance(value, str) and value.strip():
normalized['model'] = value.strip()
return
if endpoint_path == "v1/video/dub":
prefer_model('video_model', 'stt_model', 'tts_model', 'model')
elif endpoint_path == "v1/audio/clone":
prefer_model('model', 'tts_model')
elif endpoint_path == "v1/audio/convert":
prefer_model('model', 'audio_model', 'tts_model', 'stt_model')
elif endpoint_path in {"v1/audio/split", "v1/audio/denoise"}:
prefer_model('model', 'audio_model')
elif endpoint_path in {"v1/images/faceswap", "v1/images/outfit"}:
prefer_model('model', 'image_model', 'video_model')
elif endpoint_path in {"v1/images/to3d", "v1/images/from3d", "v1/video/to3d", "v1/video/from3d", "v1/3d/generate"}:
prefer_model('model', 'render_model', 'image_model', 'video_model')
return normalized
@router.get("/api/u/{username}/models")
async def user_list_models(request: Request, username: str):
user_id = getattr(request.state, 'user_id', None)
......@@ -391,6 +418,7 @@ async def _user_generic_proxy(request: Request, username: str, body: dict, endpo
"""Resolve provider from body['model'] scoped to the user and forward to provider endpoint."""
user_id = _check_user_access(request, username)
handler = _get_user_handler('request', user_id)
body = _normalize_studio_proxy_body(endpoint_path, body)
model = body.get('model', '')
provider_id, actual_model = parse_provider_from_model(model)
if not provider_id:
......@@ -459,10 +487,30 @@ async def user_audio_translations(request: Request, username: str, body: dict):
async def user_audio_generations(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/generations")
@router.post("/api/u/{username}/audio/generate")
async def user_audio_generate_alias(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/generations")
@router.post("/api/u/{username}/audio/translate")
async def user_audio_translate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/translate")
@router.post("/api/u/{username}/audio/stems")
async def user_audio_stems(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/split")
@router.post("/api/u/{username}/audio/cleanup")
async def user_audio_cleanup(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/denoise")
@router.post("/api/u/{username}/audio/clone")
async def user_audio_clone(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/clone")
@router.post("/api/u/{username}/audio/convert")
async def user_audio_convert(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/convert")
@router.post("/api/u/{username}/audio/identify")
async def user_audio_identify(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/audio/identify")
......@@ -522,6 +570,10 @@ async def user_image_detect(request: Request, username: str, body: dict):
async def user_image_segment(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/segment")
@router.post("/api/u/{username}/images/depth")
async def user_image_depth(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/depth")
@router.post("/api/u/{username}/images/restore")
async def user_image_restore(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/restore")
......@@ -538,6 +590,30 @@ async def user_image_style_transfer(request: Request, username: str, body: dict)
async def user_image_remove_bg(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/remove-bg")
@router.post("/api/u/{username}/images/faceswap")
async def user_image_faceswap(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/faceswap")
@router.post("/api/u/{username}/images/deblur")
async def user_image_deblur(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/deblur")
@router.post("/api/u/{username}/images/unpixelate")
async def user_image_unpixelate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/unpixelate")
@router.post("/api/u/{username}/images/outfit")
async def user_image_outfit(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/outfit")
@router.post("/api/u/{username}/images/to3d")
async def user_image_to3d(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/to3d")
@router.post("/api/u/{username}/images/from3d")
async def user_image_from3d(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/images/from3d")
# ── Video ─────────────────────────────────────────────────────────────────────
......@@ -565,6 +641,26 @@ async def user_video_transcriptions(request: Request, username: str, body: dict)
async def user_video_upscale(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/upscale")
@router.post("/api/u/{username}/video/interpolate")
async def user_video_interpolate(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/interpolate")
@router.post("/api/u/{username}/video/subtitle")
async def user_video_subtitle(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/subtitle")
@router.post("/api/u/{username}/video/dub")
async def user_video_dub(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/dub")
@router.post("/api/u/{username}/video/to3d")
async def user_video_to3d(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/to3d")
@router.post("/api/u/{username}/video/from3d")
async def user_video_from3d(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/video/from3d")
# ── Embeddings ────────────────────────────────────────────────────────────────
......@@ -573,6 +669,216 @@ async def user_embeddings(request: Request, username: str, body: dict):
return await _user_generic_proxy(request, username, body, "v1/embeddings")
def _studio_user_scope(request: Request, username: str) -> tuple[str, Optional[int]]:
user_id = _check_user_access(request, username)
return "user", user_id
@router.get("/api/u/{username}/archive")
async def user_studio_archive(request: Request, username: str):
scope, owner_id = _studio_user_scope(request, username)
return {"files": studio_service.list_archive(scope, owner_id)}
@router.delete("/api/u/{username}/archive/{filename}")
async def user_studio_archive_delete(request: Request, username: str, filename: str):
scope, owner_id = _studio_user_scope(request, username)
archive_dir = studio_service._scope_dir(studio_service.archive_dir, scope, owner_id)
target = archive_dir / filename
if target.exists():
target.unlink()
return {"success": True}
@router.get("/api/u/{username}/characters")
async def user_studio_characters(request: Request, username: str):
scope, owner_id = _studio_user_scope(request, username)
return {"characters": studio_service.list_characters(scope, owner_id)}
@router.get("/api/u/{username}/characters/{name}")
async def user_studio_character_detail(request: Request, username: str, name: str):
scope, owner_id = _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 item
@router.post("/api/u/{username}/characters/extract")
async def user_studio_character_extract(request: Request, username: str, body: dict):
scope, owner_id = _studio_user_scope(request, username)
return studio_service.save_character(scope, owner_id, body)
@router.post("/api/u/{username}/characters/generate")
async def user_studio_character_generate(request: Request, username: str, body: dict):
scope, owner_id = _studio_user_scope(request, username)
payload = dict(body)
payload.setdefault("images", [])
return studio_service.save_character(scope, owner_id, payload)
@router.get("/api/u/{username}/characters/{name}/thumbnail")
async def user_studio_character_thumbnail(request: Request, username: str, name: str):
scope, owner_id = _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("/api/u/{username}/environments")
async def user_studio_environments(request: Request, username: str):
scope, owner_id = _studio_user_scope(request, username)
return {"environments": studio_service.list_environments(scope, owner_id)}
@router.get("/api/u/{username}/environments/{name}")
async def user_studio_environment_detail(request: Request, username: str, name: str):
scope, owner_id = _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 item
@router.get("/api/u/{username}/environments/{name}/thumbnail")
async def user_studio_environment_thumbnail(request: Request, username: str, name: str):
scope, owner_id = _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.post("/api/u/{username}/environments/extract")
async def user_studio_environment_extract(request: Request, username: str, body: dict):
scope, owner_id = _studio_user_scope(request, username)
return studio_service.save_environment(scope, owner_id, body)
@router.post("/api/u/{username}/environments/generate")
async def user_studio_environment_generate(request: Request, username: str, body: dict):
scope, owner_id = _studio_user_scope(request, username)
payload = dict(body)
payload.setdefault("images", [])
return studio_service.save_environment(scope, owner_id, payload)
@router.get("/api/u/{username}/audio/voices")
async def user_studio_audio_voices(request: Request, username: str):
scope, owner_id = _studio_user_scope(request, username)
return {"voices": studio_service.list_voices(scope, owner_id)}
@router.post("/api/u/{username}/audio/voices")
async def user_studio_audio_voice_create(request: Request, username: str):
form = await request.form()
scope, owner_id = _studio_user_scope(request, username)
payload = {
"name": str(form.get("name") or f"voice-{int(time.time())}"),
"description": str(form.get("description") or ""),
"samples": [],
}
return studio_service.save_voice(scope, owner_id, payload)
@router.post("/api/u/{username}/audio/voices/extract")
async def user_studio_audio_voice_extract(request: Request, username: str, body: dict):
scope, owner_id = _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 studio_service.save_voice(scope, owner_id, payload)
@router.delete("/api/u/{username}/audio/voices/{name}")
async def user_studio_audio_voice_delete(request: Request, username: str, name: str):
scope, owner_id = _studio_user_scope(request, username)
studio_service.delete_voice(scope, owner_id, name)
return {"success": True}
@router.get("/api/u/{username}/pipelines/step-types")
async def user_studio_pipeline_step_types(request: Request, username: str):
_studio_user_scope(request, username)
return {"step_types": studio_service.pipeline_step_types()}
@router.get("/api/u/{username}/studio/function-bindings")
async def user_studio_function_bindings(request: Request, username: str):
scope, owner_id = _studio_user_scope(request, username)
return {
"bindings": studio_service.list_function_bindings(scope, owner_id),
"definitions": studio_service.function_binding_definitions(),
}
@router.put("/api/u/{username}/studio/function-bindings/{binding_id}")
async def user_studio_function_binding_save(request: Request, username: str, binding_id: str, body: dict):
scope, owner_id = _studio_user_scope(request, username)
bindings = studio_service.save_function_binding(scope, owner_id, binding_id, body.get("roles") or {})
return {"bindings": bindings, "binding_id": binding_id}
@router.delete("/api/u/{username}/studio/function-bindings/{binding_id}")
async def user_studio_function_binding_delete(request: Request, username: str, binding_id: str):
scope, owner_id = _studio_user_scope(request, username)
bindings = studio_service.delete_function_binding(scope, owner_id, binding_id)
return {"bindings": bindings, "binding_id": binding_id}
@router.get("/api/u/{username}/pipelines/custom")
async def user_studio_pipeline_custom_list(request: Request, username: str):
scope, owner_id = _studio_user_scope(request, username)
return {"pipelines": studio_service.list_pipelines(scope, owner_id)}
@router.post("/api/u/{username}/pipelines/custom")
async def user_studio_pipeline_custom_create(request: Request, username: str, body: dict):
scope, owner_id = _studio_user_scope(request, username)
return {"pipeline": studio_service.save_pipeline(scope, owner_id, body)}
@router.put("/api/u/{username}/pipelines/custom/{pipeline_id}")
async def user_studio_pipeline_custom_update(request: Request, username: str, pipeline_id: str, body: dict):
scope, owner_id = _studio_user_scope(request, username)
payload = dict(body)
payload["id"] = pipeline_id
return {"pipeline": studio_service.save_pipeline(scope, owner_id, payload)}
@router.delete("/api/u/{username}/pipelines/custom/{pipeline_id}")
async def user_studio_pipeline_custom_delete(request: Request, username: str, pipeline_id: str):
scope, owner_id = _studio_user_scope(request, username)
studio_service.delete_pipeline(scope, owner_id, pipeline_id)
return {"success": True}
@router.post("/api/u/{username}/pipelines/custom/{pipeline_id}/run")
async def user_studio_pipeline_custom_run(request: Request, username: str, pipeline_id: str, body: dict):
scope, owner_id = _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.update(body or {})
payload["_api_base"] = f"/api/u/{username}"
return studio_service.run_pipeline(payload)
@router.post("/api/u/{username}/pipelines/run")
async def user_studio_pipeline_run(request: Request, username: str, body: dict):
_studio_user_scope(request, username)
payload = dict(body or {})
payload["_api_base"] = f"/api/u/{username}"
return studio_service.run_pipeline(payload)
# ── Text / NLP ────────────────────────────────────────────────────────────────
@router.post("/api/u/{username}/moderations")
......
......@@ -24,6 +24,8 @@ from pathlib import Path
import json
from typing import Any, Dict, Iterable, List, Optional
from aisbf.studio_adapters import effective_studio_adapter, infer_studio_adapter_profile
STUDIO_CAPABILITY_MAP = {
"t2t": "chat",
......@@ -63,6 +65,41 @@ STUDIO_CAPABILITY_MAP = {
"animation": "animation",
}
STUDIO_CAPABILITY_CHOICES = [
"chat",
"vision",
"image_generation",
"image_edit",
"video_generation",
"video_understanding",
"audio_input",
"transcription",
"speech_generation",
"audio_generation",
"audio_to_audio",
"embeddings",
"tool_use",
"reasoning",
"code_generation",
"code_completion",
"translation",
"summarization",
"classification",
"sentiment_analysis",
"ner",
"question_answering",
"search",
"moderation",
"fine_tuning",
"multimodal",
"ocr",
"image_captioning",
"object_detection",
"segmentation",
"3d_generation",
"animation",
]
DEFAULT_CHAT_PROVIDER_TYPES = {"openai", "anthropic", "google", "kilo", "claude", "qwen", "codex"}
NON_CHAT_MEDIA_TOKENS = {
"dall-e",
......@@ -112,6 +149,10 @@ def normalize_capabilities(values: Optional[Iterable[str]]) -> List[str]:
return _dedupe(normalized)
def serialize_studio_capability_choices() -> List[str]:
return list(STUDIO_CAPABILITY_CHOICES)
def stamp_inferred_capabilities(model: Dict[str, Any], provider_type: str) -> Dict[str, Any]:
stamped = dict(model)
capability_result = infer_model_capabilities(
......@@ -154,6 +195,7 @@ def infer_model_capabilities(
output_modalities = architecture.get("output_modalities") or []
if not capabilities:
metadata_text = json.dumps(provider_metadata, sort_keys=True).lower() if provider_metadata else ""
if not any(token in name for token in ["embedding", "embed", "whisper", "tts", *NON_CHAT_MEDIA_TOKENS]):
capabilities.append("chat")
if any(token in name for token in ["vision", "gpt-4-turbo", "gpt-4o", "claude-3", "gemini-1.5", "gemini-2.0", "gemini-pro-vision", "llava", "blip"]):
......@@ -172,6 +214,8 @@ def infer_model_capabilities(
capabilities.append("speech_generation")
if any(token in name for token in ["musicgen", "audiogen", "riffusion", "a2a"]):
capabilities.append("audio_generation")
if any(token in name for token in ["voice", "audio-to-audio", "voice conversion", "rvc", "a2a"]):
capabilities.append("audio_to_audio")
if any(token in name for token in ["embedding", "embed", "ada-002", "bge", "e5", "instructor"]):
capabilities.append("embeddings")
if any(token in name for token in ["gpt-4", "gpt-3.5-turbo", "claude-3", "gemini", "function", "tool"]):
......@@ -180,6 +224,36 @@ def infer_model_capabilities(
capabilities.extend(["code_generation", "code_completion"])
if any(token in name for token in ["reasoning", "cot", "o1", "o3"]):
capabilities.append("reasoning")
if any(token in name for token in ["ocr"]):
capabilities.extend(["ocr", "image_captioning"])
if any(token in name for token in ["detect", "detection", "yolo"]):
capabilities.append("object_detection")
if any(token in name for token in ["segment", "segmentation", "sam"]):
capabilities.append("segmentation")
if any(token in name for token in ["3d", "mesh", "gaussian splat", "nerf"]):
capabilities.append("3d_generation")
if any(token in name for token in ["animate", "animation"]):
capabilities.append("animation")
if metadata_text:
if 'image' in metadata_text and 'input_modalit' in metadata_text:
capabilities.append("vision")
if 'audio' in metadata_text and 'input_modalit' in metadata_text:
capabilities.append("audio_input")
if 'transcrib' in metadata_text or 'speech_to_text' in metadata_text:
capabilities.extend(["audio_input", "transcription"])
if 'text_to_speech' in metadata_text or 'speech_generation' in metadata_text:
capabilities.append("speech_generation")
if 'audio_generation' in metadata_text or 'text-to-audio' in metadata_text:
capabilities.append("audio_generation")
if 'audio_to_audio' in metadata_text or 'voice conversion' in metadata_text:
capabilities.append("audio_to_audio")
if 'embedding' in metadata_text:
capabilities.append("embeddings")
if 'tool' in metadata_text or 'function_call' in metadata_text:
capabilities.append("tool_use")
if 'moderat' in metadata_text:
capabilities.append("moderation")
if "image" in input_modalities:
capabilities.append("vision")
......@@ -397,6 +471,7 @@ def _build_provider_entries(scope: str, owner_id: Optional[int], providers: Dict
)
metadata = {
"provider_type": provider_type,
"provider_endpoint": model.get("endpoint") or provider_config.get("endpoint") if isinstance(provider_config, dict) else getattr(provider_config, "endpoint", None),
}
if model.get("context_length") is not None:
metadata["context_length"] = model.get("context_length")
......@@ -414,6 +489,12 @@ def _build_provider_entries(scope: str, owner_id: Optional[int], providers: Dict
metadata["capability_source"] = capability_result.source
if capability_result.notes:
metadata["capability_notes"] = capability_result.notes
metadata["studio_adapter"] = effective_studio_adapter(provider_type, model)
metadata["studio_adapter_profile"] = infer_studio_adapter_profile(provider_id, provider_type, {**model, **metadata})
if model.get("studio_adapter_override") is not None:
metadata["studio_adapter_override"] = model.get("studio_adapter_override")
if model.get("studio_adapter_profile_override") is not None:
metadata["studio_adapter_profile_override"] = model.get("studio_adapter_profile_override")
entries.append(
build_catalog_entry(
......
"""
Studio workflow adapter helpers.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
def _join_non_empty(parts: list[str], fallback: str) -> str:
cleaned = [part.strip() for part in parts if isinstance(part, str) and part.strip()]
return ". ".join(cleaned) if cleaned else fallback
def _provider_signature(provider_id: str, endpoint: str) -> str:
provider_id = (provider_id or "").strip().lower()
endpoint = (endpoint or "").strip().lower()
return f"{provider_id} {endpoint}".strip()
def _is_openrouter_like(signature: str) -> bool:
return any(token in signature for token in ["openrouter", "api.kilo.ai", "kilo"])
def _is_github_models_like(signature: str) -> bool:
return any(token in signature for token in ["github", "models.inference.ai.azure.com", "githubusercontent"])
def _is_azure_openai_like(signature: str) -> bool:
return any(token in signature for token in [".openai.azure.com", "azure openai", "azure-"])
def _is_qwen_like(signature: str, provider_type: str) -> bool:
return provider_type == "qwen" or any(token in signature for token in ["dashscope", "qwen", "aliyuncs.com"])
def _is_claude_oauth_like(signature: str, provider_type: str) -> bool:
return provider_type == "claude" or any(token in signature for token in ["claude.ai", "anthropic", "claude"])
def _is_codex_like(signature: str, provider_type: str) -> bool:
return provider_type == "codex" or any(token in signature for token in ["chatgpt.com/backend-api", "auth.openai.com", "codex"])
def _is_kiro_like(signature: str, provider_type: str) -> bool:
return provider_type == "kiro" or any(token in signature for token in ["kiro", "amazon q", "amazonaws.com"])
def _media_hint(body: Dict[str, Any], *keys: str) -> str:
bits: list[str] = []
for key in keys:
value = body.get(key)
if isinstance(value, str) and value.strip():
bits.append(value.strip())
return _join_non_empty(bits, "") if bits else ""
def _response_url_hint(signature: str) -> bool:
return _is_openrouter_like(signature) or _is_azure_openai_like(signature)
STUDIO_ADAPTER_CHOICES = [
"auto",
"openai_chat_media",
"openai_native_media",
"anthropic_multimodal",
"google_gemini_media",
"ollama_openai_compat",
"passthrough",
]
STUDIO_ADAPTER_PROFILE_CHOICES = [
"auto",
"openai_default",
"openai_responses_style",
"openrouter_media",
"kilo_openrouter",
"github_models",
"azure_openai_media",
"anthropic_default",
"claude_oauth",
"gemini_default",
"qwen_dashscope",
"ollama_default",
"passthrough",
]
def serialize_studio_adapter_choices() -> list[str]:
return list(STUDIO_ADAPTER_CHOICES)
def serialize_studio_adapter_profile_choices() -> list[str]:
return list(STUDIO_ADAPTER_PROFILE_CHOICES)
def infer_studio_adapter(provider_type: str, model: Optional[Dict[str, Any]] = None) -> str:
provider_type = (provider_type or "openai").strip().lower()
model = model or {}
explicit = (model.get("studio_adapter") or "").strip()
if explicit and explicit != "auto":
return explicit
caps = set(model.get("studio_capabilities") or model.get("capabilities") or [])
if provider_type == "anthropic":
return "anthropic_multimodal"
if provider_type == "google":
return "google_gemini_media"
if provider_type == "ollama":
return "ollama_openai_compat"
if provider_type in {"openai", "qwen", "codex", "kilocode", "kilo", "claude", "kiro"}:
media_caps = {
"image_generation", "image_edit", "video_generation", "audio_generation",
"audio_to_audio", "speech_generation", "transcription", "3d_generation",
}
return "openai_native_media" if caps & media_caps else "openai_chat_media"
return "passthrough"
def effective_studio_adapter(provider_type: str, model_metadata: Optional[Dict[str, Any]] = None) -> str:
model_metadata = model_metadata or {}
override = (model_metadata.get("studio_adapter_override") or model_metadata.get("studio_adapter") or "").strip()
if override and override != "auto":
return override
return infer_studio_adapter(provider_type, model_metadata)
def infer_studio_adapter_profile(provider_id: str, provider_type: str, model_metadata: Optional[Dict[str, Any]] = None) -> str:
model_metadata = model_metadata or {}
override = (model_metadata.get("studio_adapter_profile_override") or model_metadata.get("studio_adapter_profile") or "").strip()
if override and override != "auto":
return override
provider_id = (provider_id or "").strip().lower()
provider_type = (provider_type or "").strip().lower()
endpoint = str(model_metadata.get("provider_endpoint") or model_metadata.get("endpoint") or "").lower()
signature = _provider_signature(provider_id, endpoint)
if provider_type == "google":
return "gemini_default"
if provider_type == "anthropic":
return "anthropic_default"
if provider_type == "claude" or _is_claude_oauth_like(signature, provider_type):
return "claude_oauth"
if provider_type == "ollama":
return "ollama_default"
if _is_qwen_like(signature, provider_type):
return "qwen_dashscope"
if provider_id == "kilo" or _is_openrouter_like(signature):
return "kilo_openrouter"
if _is_github_models_like(signature):
return "github_models"
if _is_azure_openai_like(signature):
return "azure_openai_media"
if _is_codex_like(signature, provider_type):
return "openai_responses_style"
if _is_kiro_like(signature, provider_type):
return "openai_default"
if "openrouter" in signature:
return "openrouter_media"
if provider_type in {"openai", "codex", "qwen", "kilocode", "kilo", "claude"}:
return "openai_responses_style" if any(token in provider_id for token in ["responses", "azure", "github"]) else "openai_default"
return "passthrough"
def adapt_studio_payload(adapter: str, endpoint_path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
body = dict(payload or {})
if adapter in {"openai_chat_media", "ollama_openai_compat"}:
if endpoint_path == "v1/video/dub":
prompt_bits = ["Dub this video"]
if body.get("source_lang"):
prompt_bits.append(f"from {body['source_lang']}")
if body.get("target_lang"):
prompt_bits.append(f"to {body['target_lang']}")
if body.get("burn_subtitles"):
prompt_bits.append("and burn subtitles")
body.setdefault("input", " ".join(prompt_bits))
elif endpoint_path == "v1/audio/clone":
if "text" in body and "input" not in body:
body["input"] = body.pop("text")
if "ref_text" in body and "transcript" not in body:
body["transcript"] = body["ref_text"]
elif endpoint_path == "v1/audio/convert":
if "target_voice" in body and "voice_reference" not in body:
body["voice_reference"] = body["target_voice"]
elif endpoint_path == "v1/images/faceswap":
if "target" in body and body.get("target_type") == "video" and "video" not in body:
body["video"] = body.pop("target")
elif "target" in body and "image" not in body:
body["image"] = body.pop("target")
if "source_face" in body and "reference_image" not in body:
body["reference_image"] = body["source_face"]
elif adapter == "anthropic_multimodal":
if endpoint_path == "v1/audio/clone":
text = body.get("text") or body.get("input") or ""
transcript = body.get("ref_text") or body.get("transcript") or ""
body["input"] = f"Clone the reference voice and say: {text}\nReference transcript: {transcript}".strip()
elif endpoint_path == "v1/audio/convert":
body["input"] = "Convert the source audio into the target voice while preserving timing and prosody."
elif endpoint_path == "v1/video/dub":
source_lang = body.get("source_lang") or "source language"
target_lang = body.get("target_lang") or "target language"
body["input"] = f"Dub this video from {source_lang} to {target_lang}."
elif adapter == "google_gemini_media":
if endpoint_path in {"v1/audio/clone", "v1/audio/convert", "v1/video/dub"} and "input" not in body:
body["input"] = body.get("text") or body.get("prompt") or body.get("notes") or "Process the provided media."
return body
def adapt_studio_payload_with_profile(adapter: str, profile: str, endpoint_path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
body = adapt_studio_payload(adapter, endpoint_path, payload)
signature = _provider_signature(
str(body.get("_studio_provider_id") or body.get("provider_id") or ""),
str(body.get("_studio_provider_endpoint") or body.get("provider_endpoint") or body.get("endpoint") or ""),
)
if _response_url_hint(signature):
body.setdefault("response_format", "url")
if profile == "openai_responses_style":
if endpoint_path == "v1/audio/clone":
body["input"] = _join_non_empty([
body.get("input"),
body.get("text"),
f"Use voice profile {body.get('voice_name')}" if body.get("voice_name") else "",
f"Reference transcript: {body.get('transcript') or body.get('ref_text')}" if (body.get("transcript") or body.get("ref_text")) else "",
], "Process the provided media.")
if _is_openrouter_like(signature):
body["modalities"] = ["text", "audio"]
elif endpoint_path == "v1/audio/convert":
body["input"] = _join_non_empty([
body.get("input"),
"Convert the provided source audio into the target voice.",
f"Voice profile: {body.get('voice_name')}" if body.get("voice_name") else "",
f"Pitch shift: {body.get('pitch_shift')}" if body.get("pitch_shift") not in (None, "") else "",
], "Process the provided media.")
if _is_openrouter_like(signature):
body["modalities"] = ["text", "audio"]
elif endpoint_path == "v1/video/dub":
body["input"] = _join_non_empty([
body.get("input"),
f"Dub the provided video from {body.get('source_lang') or 'source language'} to {body.get('target_lang') or 'target language'}.",
"Burn subtitles into the output." if body.get("burn_subtitles") else "",
], "Process the provided media.")
elif endpoint_path in {"v1/images/faceswap", "v1/images/outfit", "v1/images/to3d", "v1/images/from3d"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
"Process the provided image transformation.",
], "Process the provided media.")
elif profile == "openrouter_media":
if endpoint_path == "v1/video/dub":
body.setdefault("response_format", "url")
body["prompt"] = _join_non_empty([
body.get("prompt"),
body.get("input"),
f"Dub the provided video from {body.get('source_lang') or 'source language'} to {body.get('target_lang') or 'target language'}.",
], "Dub the provided video")
elif endpoint_path in {"v1/audio/clone", "v1/audio/convert"}:
body["prompt"] = _join_non_empty([
body.get("prompt"),
body.get("input"),
body.get("text"),
f"Use voice profile {body.get('voice_name')}" if body.get("voice_name") else "",
], "Process the provided audio")
body.setdefault("modalities", ["text", "audio"])
elif endpoint_path in {"v1/images/faceswap", "v1/images/outfit"}:
body.setdefault("response_format", "url")
body["prompt"] = _join_non_empty([
body.get("prompt"),
"Perform the requested image or video transformation.",
body.get("input"),
], "Process the provided media")
elif endpoint_path in {"v1/images/to3d", "v1/images/from3d", "v1/video/to3d", "v1/video/from3d", "v1/3d/generate"}:
body.setdefault("response_format", "url")
body["prompt"] = _join_non_empty([
body.get("prompt"),
body.get("input"),
body.get("notes"),
"Generate or transform 3D media from the provided source.",
], "Process the provided 3D media")
elif profile == "kilo_openrouter":
body.setdefault("response_format", "url")
if endpoint_path == "v1/video/dub":
body["prompt"] = _join_non_empty([
body.get("prompt"),
body.get("input"),
f"Dub this media from {body.get('source_lang') or 'source language'} to {body.get('target_lang') or 'target language'}.",
"Prefer provider-side multimodel orchestration when supported.",
], "Dub the provided media")
elif endpoint_path in {"v1/audio/clone", "v1/audio/convert"}:
body["prompt"] = _join_non_empty([
body.get("prompt"),
body.get("input"),
body.get("text"),
f"Reference transcript: {body.get('transcript') or body.get('ref_text')}" if (body.get("transcript") or body.get("ref_text")) else "",
"Prefer provider-side voice workflow support.",
], "Process the provided audio")
body.setdefault("modalities", ["text", "audio"])
elif endpoint_path in {"v1/images/faceswap", "v1/images/outfit", "v1/images/to3d", "v1/images/from3d", "v1/video/to3d", "v1/video/from3d", "v1/3d/generate"}:
body["prompt"] = _join_non_empty([
body.get("prompt"),
body.get("input"),
body.get("notes"),
"Prefer provider-side multimodel orchestration when supported.",
], "Process the provided media")
elif profile == "github_models":
if endpoint_path in {"v1/audio/clone", "v1/audio/convert", "v1/video/dub"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("text"),
body.get("notes"),
], "Process the provided media.")
body.pop("prompt", None)
elif endpoint_path in {"v1/images/faceswap", "v1/images/outfit", "v1/images/to3d", "v1/images/from3d", "v1/video/to3d", "v1/video/from3d", "v1/3d/generate"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("notes"),
_media_hint(body, "source_lang", "target_lang"),
], "Process the provided media.")
body.pop("prompt", None)
elif profile == "azure_openai_media":
if endpoint_path in {"v1/audio/clone", "v1/audio/convert", "v1/video/dub"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("text"),
f"Target language: {body.get('target_lang')}" if body.get("target_lang") else "",
], "Process the provided media.")
body.setdefault("response_format", "url")
elif endpoint_path in {"v1/images/faceswap", "v1/images/outfit", "v1/images/to3d", "v1/images/from3d", "v1/video/to3d", "v1/video/from3d", "v1/3d/generate"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("notes"),
"Return a provider-hosted media result when supported.",
], "Process the provided media.")
body.setdefault("response_format", "url")
elif profile == "anthropic_default":
if endpoint_path.startswith("v1/images/") and "input" not in body:
body["input"] = body.get("prompt") or "Process the provided image task."
elif profile == "claude_oauth":
if endpoint_path in {"v1/audio/clone", "v1/audio/convert", "v1/video/dub"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("text"),
body.get("notes"),
"Return structured textual guidance if native media transformation is unavailable.",
], "Process the provided media.")
body.pop("prompt", None)
elif endpoint_path.startswith("v1/images/") or endpoint_path.startswith("v1/video/") or endpoint_path.startswith("v1/3d/"):
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("notes"),
"Use uploaded media context when supported and otherwise respond with the closest textual workflow guidance.",
], "Process the provided media task.")
body.pop("prompt", None)
elif profile == "gemini_default":
if endpoint_path.startswith("v1/video/"):
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("notes"),
f"Source language: {body.get('source_lang')}" if body.get("source_lang") else "",
f"Target language: {body.get('target_lang')}" if body.get("target_lang") else "",
], "Process the provided video.")
elif endpoint_path in {"v1/audio/clone", "v1/audio/convert"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("text"),
body.get("notes"),
f"Transcript: {body.get('transcript') or body.get('ref_text')}" if (body.get("transcript") or body.get("ref_text")) else "",
], "Process the provided audio.")
elif endpoint_path.startswith("v1/images/") or endpoint_path.startswith("v1/3d/"):
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("notes"),
"Use the provided image or media context for this transformation.",
], "Process the provided image task.")
elif profile == "qwen_dashscope":
if endpoint_path in {"v1/audio/clone", "v1/audio/convert", "v1/video/dub"}:
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("text"),
body.get("notes"),
"Use DashScope-compatible multimodal processing when available.",
], "Process the provided media.")
body.pop("prompt", None)
elif endpoint_path.startswith("v1/images/") or endpoint_path.startswith("v1/video/") or endpoint_path.startswith("v1/3d/"):
body["input"] = _join_non_empty([
body.get("input"),
body.get("prompt"),
body.get("notes"),
"Adapt this request to Qwen/DashScope-compatible media instructions.",
], "Process the provided media task.")
body.pop("prompt", None)
elif profile == "ollama_default":
if endpoint_path in {"v1/audio/clone", "v1/audio/convert", "v1/video/dub"} and "prompt" not in body:
body["prompt"] = body.get("input") or body.get("text") or body.get("notes") or "Process the provided media."
elif endpoint_path.startswith("v1/images/") or endpoint_path.startswith("v1/video/") or endpoint_path.startswith("v1/3d/"):
body["prompt"] = _join_non_empty([
body.get("prompt"),
body.get("input"),
body.get("notes"),
"If native media execution is unavailable, describe the expected transformation result.",
], "Process the provided media.")
return body
"""
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
AISBF - AI Service Broker Framework || AI Should Be Free
"""
from __future__ import annotations
import re
import base64
import json
import mimetypes
import time
import asyncio
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from aisbf.database import DatabaseRegistry
class StudioService:
def __init__(self) -> None:
base_dir = Path.home() / ".aisbf" / "studio"
self.base_dir = base_dir
self.characters_dir = base_dir / "characters"
self.environments_dir = base_dir / "environments"
self.voices_dir = base_dir / "voices"
self.archive_dir = base_dir / "archive"
self.pipelines_dir = base_dir / "pipelines"
for directory in (
self.characters_dir,
self.environments_dir,
self.voices_dir,
self.archive_dir,
self.pipelines_dir,
):
directory.mkdir(parents=True, exist_ok=True)
STUDIO_FUNCTION_BINDINGS = [
{
"id": "chat",
"label": "Chat",
"kind": "single",
"category": "chat",
"endpoint": "/chat/completions",
"roles": [
{"key": "model", "label": "Chat model", "capabilities": ["text_generation"]},
],
},
{
"id": "img-gen",
"label": "Image generate",
"kind": "single",
"category": "image",
"endpoint": "/images/generations",
"roles": [
{"key": "model", "label": "Image model", "capabilities": ["image_generation"]},
],
},
{
"id": "img-edit",
"label": "Image edit",
"kind": "single",
"category": "image",
"endpoint": "/images/edits",
"roles": [
{"key": "model", "label": "Edit model", "capabilities": ["image_to_image"]},
],
},
{
"id": "img-inpaint",
"label": "Inpaint",
"kind": "single",
"category": "image",
"endpoint": "/images/inpaint",
"roles": [
{"key": "model", "label": "Inpaint model", "capabilities": ["inpainting"]},
],
},
{
"id": "img-upscale",
"label": "Image upscale",
"kind": "single",
"category": "image",
"endpoint": "/images/upscale",
"roles": [
{"key": "model", "label": "Upscale model", "capabilities": ["image_upscaling"]},
],
},
{
"id": "img-depth",
"label": "Depth",
"kind": "single",
"category": "image",
"endpoint": "/images/depth",
"roles": [
{"key": "model", "label": "Depth model", "capabilities": ["depth_estimation"]},
],
},
{
"id": "img-seg",
"label": "Segment",
"kind": "single",
"category": "image",
"endpoint": "/images/segment",
"roles": [
{"key": "model", "label": "Segmentation model", "capabilities": ["image_segmentation"]},
],
},
{
"id": "img-faceswap",
"label": "Face swap",
"kind": "single",
"category": "image",
"endpoint": "/images/faceswap",
"roles": [
{"key": "model", "label": "Face swap model", "capabilities": ["image_to_image"]},
],
},
{
"id": "img-deblur",
"label": "Deblur",
"kind": "single",
"category": "image",
"endpoint": "/images/deblur",
"roles": [
{"key": "model", "label": "Deblur model", "capabilities": ["image_to_image", "image_upscaling"]},
],
},
{
"id": "img-unpix",
"label": "Unpixelate",
"kind": "single",
"category": "image",
"endpoint": "/images/unpixelate",
"roles": [
{"key": "model", "label": "Restore model", "capabilities": ["image_to_image", "image_upscaling"]},
],
},
{
"id": "img-outfit",
"label": "Outfit change",
"kind": "single",
"category": "image",
"endpoint": "/images/outfit",
"roles": [
{"key": "model", "label": "Outfit model", "capabilities": ["image_to_image", "inpainting"]},
],
},
{
"id": "img-to3d",
"label": "2D to 3D",
"kind": "single",
"category": "3d",
"endpoint": "/images/to3d",
"roles": [
{"key": "model", "label": "2D to 3D model", "capabilities": ["image_to_3d", "model_3d_generation"]},
],
},
{
"id": "img-from3d",
"label": "3D to 2D",
"kind": "single",
"category": "3d",
"endpoint": "/images/from3d",
"roles": [
{"key": "model", "label": "3D render model", "capabilities": ["model_3d_to_image", "model_3d_generation"]},
],
},
{
"id": "vid-t2v",
"label": "Text to video",
"kind": "single",
"category": "video",
"endpoint": "/video/generations",
"roles": [
{"key": "model", "label": "Video model", "capabilities": ["video_generation"]},
],
},
{
"id": "vid-i2v",
"label": "Image to video",
"kind": "single",
"category": "video",
"endpoint": "/video/generations",
"roles": [
{"key": "model", "label": "I2V model", "capabilities": ["image_to_video", "video_generation"]},
],
},
{
"id": "vid-v2v",
"label": "Video to video",
"kind": "single",
"category": "video",
"endpoint": "/video/generations",
"roles": [
{"key": "model", "label": "V2V model", "capabilities": ["video_to_video", "video_generation"]},
],
},
{
"id": "vid-ti2v",
"label": "Ti2V",
"kind": "single",
"category": "video",
"endpoint": "/video/generations",
"roles": [
{"key": "model", "label": "Ti2V model", "capabilities": ["video_generation", "image_to_video", "video_to_video"]},
],
},
{
"id": "vid-interp",
"label": "Interpolate",
"kind": "single",
"category": "video",
"endpoint": "/video/interpolate",
"roles": [
{"key": "model", "label": "Interpolation model", "capabilities": ["video_interpolation", "video_generation"]},
],
},
{
"id": "vid-sub",
"label": "Subtitles",
"kind": "single",
"category": "video",
"endpoint": "/video/subtitle",
"roles": [
{"key": "model", "label": "Subtitle model", "capabilities": ["subtitle_generation", "speech_to_text"]},
],
},
{
"id": "vid-dub",
"label": "Video dub",
"kind": "multi",
"category": "video",
"endpoint": "/video/dub",
"roles": [
{"key": "stt_model", "label": "Speech to text", "capabilities": ["speech_to_text"]},
{"key": "tts_model", "label": "Text to speech", "capabilities": ["text_to_speech"]},
{"key": "video_model", "label": "Video model", "capabilities": ["video_to_video", "video_generation"], "optional": True},
],
},
{
"id": "vid-up",
"label": "Video upscale",
"kind": "single",
"category": "video",
"endpoint": "/video/upscale",
"roles": [
{"key": "model", "label": "Upscale model", "capabilities": ["video_upscaling", "video_generation"]},
],
},
{
"id": "vid-faceswap",
"label": "Video face swap",
"kind": "single",
"category": "video",
"endpoint": "/images/faceswap",
"roles": [
{"key": "model", "label": "Face swap model", "capabilities": ["image_to_image", "video_to_video"]},
],
},
{
"id": "vid-outfit",
"label": "Video outfit change",
"kind": "single",
"category": "video",
"endpoint": "/images/outfit",
"roles": [
{"key": "model", "label": "Outfit model", "capabilities": ["image_to_image", "inpainting", "video_to_video"]},
],
},
{
"id": "vid-to3d",
"label": "Video to 3D",
"kind": "single",
"category": "3d",
"endpoint": "/video/to3d",
"roles": [
{"key": "model", "label": "Video to 3D model", "capabilities": ["video_to_3d", "model_3d_generation"]},
],
},
{
"id": "vid-from3d",
"label": "3D to video",
"kind": "single",
"category": "3d",
"endpoint": "/video/from3d",
"roles": [
{"key": "model", "label": "3D video render model", "capabilities": ["video_generation", "model_3d_generation"]},
],
},
{
"id": "aud-gen",
"label": "Audio generate",
"kind": "single",
"category": "audio",
"endpoint": "/audio/generate",
"roles": [
{"key": "model", "label": "Audio model", "capabilities": ["audio_generation"]},
],
},
{
"id": "aud-tts",
"label": "Text to speech",
"kind": "single",
"category": "audio",
"endpoint": "/audio/speech",
"roles": [
{"key": "model", "label": "TTS model", "capabilities": ["text_to_speech"]},
],
},
{
"id": "aud-clone",
"label": "Voice clone",
"kind": "single",
"category": "audio",
"endpoint": "/audio/clone",
"roles": [
{"key": "model", "label": "Voice clone model", "capabilities": ["text_to_speech"]},
],
},
{
"id": "aud-convert",
"label": "Voice convert",
"kind": "single",
"category": "audio",
"endpoint": "/audio/convert",
"roles": [
{"key": "model", "label": "Voice convert model", "capabilities": ["audio_to_audio", "speech_to_text"]},
],
},
{
"id": "aud-stt",
"label": "Transcribe",
"kind": "single",
"category": "audio",
"endpoint": "/audio/transcriptions",
"roles": [
{"key": "model", "label": "STT model", "capabilities": ["speech_to_text"]},
],
},
{
"id": "aud-understand",
"label": "Audio understand",
"kind": "multi",
"category": "audio",
"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},
],
},
{
"id": "aud-music-dub",
"label": "Music dub",
"kind": "multi",
"category": "audio",
"endpoint": "/pipelines/audio-music-dub",
"roles": [
{"key": "stt_model", "label": "Speech to text", "capabilities": ["speech_to_text"]},
{"key": "tts_model", "label": "Text to speech", "capabilities": ["text_to_speech"]},
{"key": "audio_model", "label": "Audio model", "capabilities": ["audio_generation", "audio_to_audio"], "optional": True},
],
},
{
"id": "aud-stems",
"label": "Stem separation",
"kind": "single",
"category": "audio",
"endpoint": "/audio/stems",
"roles": [
{"key": "model", "label": "Stem model", "capabilities": ["audio_to_audio", "audio_generation"]},
],
},
{
"id": "aud-clean",
"label": "Audio cleanup",
"kind": "single",
"category": "audio",
"endpoint": "/audio/cleanup",
"roles": [
{"key": "model", "label": "Cleanup model", "capabilities": ["audio_to_audio", "speech_to_text"]},
],
},
{
"id": "embed",
"label": "Embeddings",
"kind": "single",
"category": "embed",
"endpoint": "/embeddings",
"roles": [
{"key": "model", "label": "Embedding model", "capabilities": ["embeddings"]},
],
},
{
"id": "3d-generate",
"label": "3D generate",
"kind": "single",
"category": "3d",
"endpoint": "/pipelines/3d-generate",
"roles": [
{"key": "model", "label": "3D model", "capabilities": ["model_3d_generation"]},
],
},
{
"id": "3d-img-to3d",
"label": "Image to 3D",
"kind": "single",
"category": "3d",
"endpoint": "/images/to3d",
"roles": [
{"key": "model", "label": "Image to 3D model", "capabilities": ["image_to_3d", "model_3d_generation"]},
],
},
{
"id": "3d-vid-to3d",
"label": "Video to 3D",
"kind": "single",
"category": "3d",
"endpoint": "/video/to3d",
"roles": [
{"key": "model", "label": "Video to 3D model", "capabilities": ["video_to_3d", "model_3d_generation"]},
],
},
{
"id": "3d-from3d",
"label": "3D render",
"kind": "single",
"category": "3d",
"endpoint": "/images/from3d",
"roles": [
{"key": "model", "label": "3D render model", "capabilities": ["model_3d_to_image", "model_3d_generation"]},
],
},
{
"id": "pipe-image-to-video",
"label": "Pipeline image to video",
"kind": "multi",
"category": "pipe",
"endpoint": "/pipelines/image-to-video",
"roles": [
{"key": "image_model", "label": "Image model", "capabilities": ["image_generation"]},
{"key": "video_model", "label": "Video model", "capabilities": ["image_to_video", "video_generation"]},
],
},
{
"id": "pipe-audio-dub",
"label": "Pipeline audio dub",
"kind": "multi",
"category": "pipe",
"endpoint": "/pipelines/audio-dub",
"roles": [
{"key": "stt_model", "label": "Speech to text", "capabilities": ["speech_to_text"]},
{"key": "tts_model", "label": "Text to speech", "capabilities": ["text_to_speech"]},
],
},
]
def _scope_dir(self, root: Path, scope: str, owner_id: Optional[int]) -> Path:
name = "admin" if scope == "admin" or owner_id is None else f"user_{owner_id}"
path = root / name
path.mkdir(parents=True, exist_ok=True)
return path
def _uses_database(self, scope: str, owner_id: Optional[int]) -> bool:
return scope != "admin" and owner_id is not None
def _admin_pipelines_path(self) -> Path:
return Path.home() / ".aisbf" / "pipelines.json"
def _admin_bindings_path(self) -> Path:
return Path.home() / ".aisbf" / "studio_bindings.json"
def _slugify_pipeline_id(self, value: str) -> str:
text = (value or "pipeline").strip().lower()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = text.strip("-")
return text or "pipeline"
def load_studio_system_prompt(self, scope: str, owner_id: Optional[int]) -> str:
default_prompt = self._load_default_studio_system_prompt()
if self._uses_database(scope, owner_id) and owner_id is not None:
user_prompt = self._db().get_user_prompt(owner_id, "studio_system")
return user_prompt if user_prompt is not None else default_prompt
config_path = Path.home() / ".aisbf" / "STUDIO_SYSTEM.md"
if config_path.exists():
try:
return config_path.read_text()
except Exception:
return default_prompt
return default_prompt
def _load_default_studio_system_prompt(self) -> str:
installed_dirs = [
Path('/usr/share/aisbf'),
Path.home() / '.local' / 'share' / 'aisbf',
]
for installed_dir in installed_dirs:
prompt_file = installed_dir / 'STUDIO_SYSTEM.md'
if prompt_file.exists():
try:
return prompt_file.read_text()
except Exception:
break
source_file = Path(__file__).parent.parent / 'config' / 'STUDIO_SYSTEM.md'
if source_file.exists():
try:
return source_file.read_text()
except Exception:
pass
return "You are AiSBF, a general assistant..."
def _db(self):
return DatabaseRegistry.get_config_database()
def _item_dir(self, root: Path, scope: str, owner_id: Optional[int], name: str) -> Path:
path = self._scope_dir(root, scope, owner_id) / name
path.mkdir(parents=True, exist_ok=True)
return path
def _meta_path(self, item_dir: Path) -> Path:
return item_dir / "meta.json"
def _read_meta(self, item_dir: Path) -> Optional[Dict[str, Any]]:
meta_path = self._meta_path(item_dir)
if not meta_path.exists():
return None
try:
return json.loads(meta_path.read_text())
except Exception:
return None
def _write_meta(self, item_dir: Path, payload: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(payload)
payload.setdefault("updated_at", int(time.time()))
if "created_at" not in payload:
payload["created_at"] = payload["updated_at"]
self._meta_path(item_dir).write_text(json.dumps(payload, indent=2))
return payload
def _list_items(self, root: Path, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]:
scoped = self._scope_dir(root, scope, owner_id)
items: List[Dict[str, Any]] = []
for item_dir in sorted(scoped.iterdir()):
if not item_dir.is_dir():
continue
meta = self._read_meta(item_dir)
if meta:
items.append(meta)
items.sort(key=lambda row: row.get("updated_at", 0), reverse=True)
return items
def _delete_item(self, root: Path, scope: str, owner_id: Optional[int], name: str) -> bool:
item_dir = self._item_dir(root, scope, owner_id, name)
if not item_dir.exists():
return False
for child in item_dir.iterdir():
if child.is_file():
child.unlink()
item_dir.rmdir()
return True
def _store_uploads(self, item_dir: Path, uploads: List[str], prefix: str) -> List[str]:
stored: List[str] = []
for index, data_url in enumerate(uploads or []):
if not isinstance(data_url, str) or "," not in data_url:
continue
header, encoded = data_url.split(",", 1)
ext = ".bin"
if "image/" in header:
ext = ".png"
elif "audio/" in header:
ext = ".wav"
elif "video/" in header:
ext = ".mp4"
target = item_dir / f"{prefix}_{index}{ext}"
try:
target.write_bytes(base64.b64decode(encoded))
stored.append(target.name)
except Exception:
continue
return stored
def list_characters(self, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]:
if self._uses_database(scope, owner_id):
return self._db().list_studio_assets(owner_id, "character")
return self._list_items(self.characters_dir, scope, owner_id)
def get_character(self, scope: str, owner_id: Optional[int], name: str) -> Optional[Dict[str, Any]]:
if self._uses_database(scope, owner_id):
item = self._db().get_studio_asset(owner_id, "character", name)
else:
item = self._read_meta(self._item_dir(self.characters_dir, scope, owner_id, name))
return self._normalize_profile_item(item, "character")
def save_character(self, scope: str, owner_id: Optional[int], payload: Dict[str, Any]) -> Dict[str, Any]:
name = payload.get("name")
if self._uses_database(scope, owner_id):
existing = self._db().get_studio_asset(owner_id, "character", name) or {"name": name}
images = payload.get("images") or existing.get("ref_images", [])
meta = {
"ref_images": images,
"image_count": len(images),
"scope": scope,
"owner_id": owner_id,
}
return self._db().upsert_studio_asset(owner_id, "character", name, payload.get("description", ""), meta, images)
item_dir = self._item_dir(self.characters_dir, scope, owner_id, name)
existing = self._read_meta(item_dir) or {"name": name}
images = payload.get("images") or []
stored = self._store_uploads(item_dir, images, "ref")
existing.update({
"name": name,
"description": payload.get("description", ""),
"kind": "character",
"ref_images": stored or existing.get("ref_images", []),
"thumbnail_url": f"/admin/api/characters/{name}/thumbnail",
"scope": scope,
"owner_id": owner_id,
})
return self._write_meta(item_dir, existing)
def list_environments(self, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]:
if self._uses_database(scope, owner_id):
return self._db().list_studio_assets(owner_id, "environment")
return self._list_items(self.environments_dir, scope, owner_id)
def get_environment(self, scope: str, owner_id: Optional[int], name: str) -> Optional[Dict[str, Any]]:
if self._uses_database(scope, owner_id):
item = self._db().get_studio_asset(owner_id, "environment", name)
else:
item = self._read_meta(self._item_dir(self.environments_dir, scope, owner_id, name))
return self._normalize_profile_item(item, "environment")
def save_environment(self, scope: str, owner_id: Optional[int], payload: Dict[str, Any]) -> Dict[str, Any]:
name = payload.get("name")
if self._uses_database(scope, owner_id):
existing = self._db().get_studio_asset(owner_id, "environment", name) or {"name": name}
images = payload.get("images") or existing.get("ref_images", [])
meta = {
"ref_images": images,
"image_count": len(images),
"scope": scope,
"owner_id": owner_id,
}
return self._db().upsert_studio_asset(owner_id, "environment", name, payload.get("description", ""), meta, images)
item_dir = self._item_dir(self.environments_dir, scope, owner_id, name)
existing = self._read_meta(item_dir) or {"name": name}
images = payload.get("images") or []
stored = self._store_uploads(item_dir, images, "env")
existing.update({
"name": name,
"description": payload.get("description", ""),
"kind": "environment",
"ref_images": stored or existing.get("ref_images", []),
"thumbnail_url": f"/admin/api/environments/{name}/thumbnail",
"scope": scope,
"owner_id": owner_id,
})
return self._write_meta(item_dir, existing)
def list_voices(self, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]:
if self._uses_database(scope, owner_id):
return self._db().list_studio_assets(owner_id, "voice")
return self._list_items(self.voices_dir, scope, owner_id)
def save_voice(self, scope: str, owner_id: Optional[int], payload: Dict[str, Any]) -> Dict[str, Any]:
name = payload.get("name")
if self._uses_database(scope, owner_id):
existing = self._db().get_studio_asset(owner_id, "voice", name) or {"name": name}
samples = payload.get("samples") or existing.get("sample_files", [])
meta = {
"sample_files": samples,
"scope": scope,
"owner_id": owner_id,
}
return self._db().upsert_studio_asset(owner_id, "voice", name, payload.get("description", ""), meta, samples, payload.get("quote", existing.get("quote", "")))
item_dir = self._item_dir(self.voices_dir, scope, owner_id, name)
existing = self._read_meta(item_dir) or {"name": name}
samples = payload.get("samples") or []
stored = self._store_uploads(item_dir, samples, "voice")
existing.update({
"name": name,
"description": payload.get("description", ""),
"kind": "voice",
"sample_files": stored or existing.get("sample_files", []),
"quote": payload.get("quote", existing.get("quote", "")),
"scope": scope,
"owner_id": owner_id,
})
return self._write_meta(item_dir, existing)
def list_archive(self, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]:
scoped = self._scope_dir(self.archive_dir, scope, owner_id)
files: List[Dict[str, Any]] = []
for file_path in sorted(scoped.iterdir()):
if not file_path.is_file():
continue
mime, _ = mimetypes.guess_type(file_path.name)
if mime and mime.startswith("image"):
kind = "image"
elif mime and mime.startswith("video"):
kind = "video"
elif mime and mime.startswith("audio"):
kind = "audio"
else:
kind = "file"
stat = file_path.stat()
files.append({
"filename": file_path.name,
"url": f"/dashboard/static/studio-archive/{'admin' if scope == 'admin' or owner_id is None else f'user_{owner_id}'}/{file_path.name}",
"size": stat.st_size,
"created": int(stat.st_mtime),
"type": kind,
})
files.sort(key=lambda row: row["created"], reverse=True)
return files
def save_pipeline(self, scope: str, owner_id: Optional[int], payload: Dict[str, Any]) -> Dict[str, Any]:
pipeline_id = self._slugify_pipeline_id(payload.get("id") or payload.get("name", "pipeline"))
name = (payload.get("name") or pipeline_id).strip() or pipeline_id
description = payload.get("description", "")
steps = payload.get("steps", [])
if self._uses_database(scope, owner_id):
return self._db().upsert_studio_pipeline(owner_id, pipeline_id, name, description, steps)
pipelines_path = self._admin_pipelines_path()
existing_rows = self._read_pipelines_json(pipelines_path)
created_at = int(time.time())
for row in existing_rows:
if row.get("id") == pipeline_id:
created_at = row.get("created_at") or created_at
break
record = {
"id": pipeline_id,
"name": name,
"description": description,
"steps": steps,
"scope": scope,
"owner_id": owner_id,
"created_at": created_at,
"updated_at": int(time.time()),
}
updated_rows = [row for row in existing_rows if row.get("id") != pipeline_id]
updated_rows.insert(0, record)
self._write_pipelines_json(pipelines_path, updated_rows)
return record
def list_pipelines(self, scope: str, owner_id: Optional[int]) -> List[Dict[str, Any]]:
if self._uses_database(scope, owner_id):
return self._db().list_studio_pipelines(owner_id)
return self._read_pipelines_json(self._admin_pipelines_path())
def delete_pipeline(self, scope: str, owner_id: Optional[int], pipeline_id: str) -> bool:
if self._uses_database(scope, owner_id):
return self._db().delete_studio_pipeline(owner_id, pipeline_id)
pipelines_path = self._admin_pipelines_path()
rows = self._read_pipelines_json(pipelines_path)
updated_rows = [row for row in rows if row.get("id") != pipeline_id]
if len(updated_rows) == len(rows):
return False
self._write_pipelines_json(pipelines_path, updated_rows)
return True
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)
rows = self.list_pipelines(scope, owner_id)
for row in rows:
if row.get("id") == pipeline_id:
return row
return None
def _read_pipelines_json(self, file_path: Path) -> List[Dict[str, Any]]:
if not file_path.exists():
return []
try:
raw = file_path.read_text()
except Exception:
return []
try:
rows = json.loads(raw)
except Exception:
return []
if not isinstance(rows, list):
return []
normalized: List[Dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
pipeline_id = self._slugify_pipeline_id(str(row.get("id") or row.get("name") or "pipeline"))
normalized.append({
"id": pipeline_id,
"name": row.get("name") or pipeline_id,
"description": row.get("description") or "",
"steps": row.get("steps") or [],
"scope": row.get("scope") or "admin",
"owner_id": row.get("owner_id"),
"created_at": int(row.get("created_at") or time.time()),
"updated_at": int(row.get("updated_at") or row.get("created_at") or time.time()),
})
normalized.sort(key=lambda row: row.get("updated_at", 0), reverse=True)
return normalized
def _write_pipelines_json(self, file_path: Path, rows: List[Dict[str, Any]]) -> None:
payload = json.dumps(rows, indent=2)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(f"{payload}\n")
def _pipeline_step_binding_map(self) -> Dict[str, Dict[str, Any]]:
return {item["id"]: item for item in self.STUDIO_FUNCTION_BINDINGS}
def _normalize_pipeline_steps(self, steps: Any) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
if not isinstance(steps, list):
return normalized
known = self._pipeline_step_binding_map()
for index, step in enumerate(steps):
if not isinstance(step, dict):
continue
step_type = str(step.get("type") or "").strip()
if not step_type:
continue
meta = known.get(step_type, {})
normalized.append({
"type": step_type,
"label": str(step.get("label") or meta.get("label") or step_type).strip() or step_type,
"params": step.get("params") if isinstance(step.get("params"), dict) else {},
})
return normalized
def _pipeline_context(self, seed_input: Any, seed_story: Any, prior_steps: List[Dict[str, Any]]) -> Dict[str, Any]:
return {
"input": seed_input,
"story": seed_story,
"steps": prior_steps,
}
def _resolve_pipeline_value(self, value: Any, context: Dict[str, Any]) -> Any:
if isinstance(value, str):
pattern = re.compile(r"\{\{\s*([^{}]+?)\s*\}\}")
matches = list(pattern.finditer(value))
if not matches:
return value
def lookup(expr: str):
expr = (expr or "").strip()
if not expr:
return ""
current: Any = context
for token in expr.split('.'):
token = token.strip()
if not token:
return ""
step_match = re.fullmatch(r"step(\d+)", token)
if step_match:
idx = int(step_match.group(1))
steps = current.get("steps") if isinstance(current, dict) else None
if not isinstance(steps, list) or idx < 0 or idx >= len(steps):
return ""
current = steps[idx]
continue
if isinstance(current, dict):
current = current.get(token)
elif isinstance(current, list) and token.isdigit():
idx = int(token)
if idx < 0 or idx >= len(current):
return ""
current = current[idx]
else:
return ""
return current
if len(matches) == 1 and matches[0].span() == (0, len(value)):
return self._resolve_pipeline_value(lookup(matches[0].group(1)), context)
resolved = value
for match in reversed(matches):
replacement = lookup(match.group(1))
if replacement is None:
replacement = ""
elif isinstance(replacement, (dict, list)):
replacement = json.dumps(replacement)
else:
replacement = str(replacement)
resolved = resolved[:match.start()] + replacement + resolved[match.end():]
return resolved
if isinstance(value, dict):
return {key: self._resolve_pipeline_value(val, context) for key, val in value.items()}
if isinstance(value, list):
return [self._resolve_pipeline_value(item, context) for item in value]
return value
def _coerce_pipeline_bool(self, value: Any) -> Any:
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "yes", "on"}:
return True
if lowered in {"false", "no", "off"}:
return False
return value
def _extract_step_artifacts(self, response_payload: Any) -> Dict[str, Any]:
artifacts: Dict[str, Any] = {
"raw": response_payload,
}
if isinstance(response_payload, dict):
for key in ("output", "url", "b64_wav", "text", "input", "data", "result", "results", "steps"):
if key in response_payload:
artifacts[key] = response_payload.get(key)
choices = response_payload.get("choices")
if isinstance(choices, list) and choices:
first = choices[0] if isinstance(choices[0], dict) else None
if first:
message = first.get("message")
if isinstance(message, dict) and isinstance(message.get("content"), str):
artifacts.setdefault("output", message.get("content"))
elif isinstance(first.get("text"), str):
artifacts.setdefault("output", first.get("text"))
data = response_payload.get("data")
if isinstance(data, list) and data:
first = data[0] if isinstance(data[0], dict) else None
if first:
for key in ("url", "b64_json", "revised_prompt", "text", "embedding"):
if key in first:
artifacts.setdefault(key, first.get(key))
if "url" not in artifacts:
for key in ("video_url", "audio_url", "image_url", "file_url"):
if isinstance(response_payload.get(key), str) and response_payload.get(key).strip():
artifacts["url"] = response_payload.get(key).strip()
break
if "output" not in artifacts:
for key in ("text", "caption", "transcript", "description", "summary", "result"):
if isinstance(response_payload.get(key), str) and response_payload.get(key).strip():
artifacts["output"] = response_payload.get(key).strip()
break
elif isinstance(response_payload, str):
artifacts["output"] = response_payload
return artifacts
async def _execute_pipeline_step(self, api_base: str, step: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
known = self._pipeline_step_binding_map()
meta = known.get(step.get("type"), {})
endpoint = meta.get("endpoint")
if not endpoint:
raise ValueError(f"Unsupported pipeline step type: {step.get('type')}")
body = self._resolve_pipeline_value(deepcopy(step.get("params") or {}), context)
if isinstance(body, dict):
body = {key: self._coerce_pipeline_bool(val) for key, val in body.items()}
else:
body = {}
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=30.0)) as client:
response = await client.post(f"{api_base}{endpoint}", json=body)
response.raise_for_status()
payload = response.json()
artifacts = self._extract_step_artifacts(payload)
return {
"type": step.get("type"),
"label": step.get("label") or meta.get("label") or step.get("type") or "step",
"request": body,
"response": payload,
**artifacts,
}
def function_binding_definitions(self) -> List[Dict[str, Any]]:
return json.loads(json.dumps(self.STUDIO_FUNCTION_BINDINGS))
def _normalize_function_bindings(self, payload: Any) -> Dict[str, Dict[str, str]]:
if not isinstance(payload, dict):
return {}
normalized: Dict[str, Dict[str, str]] = {}
allowed = {item["id"]: {role["key"] for role in item.get("roles", [])} for item in self.STUDIO_FUNCTION_BINDINGS}
for binding_id, role_map in payload.items():
if binding_id not in allowed or not isinstance(role_map, dict):
continue
clean_roles: Dict[str, str] = {}
for role_key, model_id in role_map.items():
if role_key in allowed[binding_id] and isinstance(model_id, str) and model_id.strip():
clean_roles[role_key] = model_id.strip()
if clean_roles:
normalized[binding_id] = clean_roles
return normalized
def list_function_bindings(self, scope: str, owner_id: Optional[int]) -> Dict[str, Dict[str, str]]:
if self._uses_database(scope, owner_id) and owner_id is not None:
raw = self._db().get_user_prompt(owner_id, "studio_function_bindings")
if raw is None:
return {}
try:
payload = json.loads(raw)
except Exception:
return {}
return self._normalize_function_bindings(payload)
path = self._admin_bindings_path()
if not path.exists():
return {}
try:
payload = json.loads(path.read_text())
except Exception:
return {}
return self._normalize_function_bindings(payload)
def save_function_binding(self, scope: str, owner_id: Optional[int], binding_id: str, roles: Dict[str, str]) -> Dict[str, Dict[str, str]]:
bindings = self.list_function_bindings(scope, owner_id)
updated = dict(bindings)
normalized = self._normalize_function_bindings({binding_id: roles})
if binding_id in normalized:
updated[binding_id] = normalized[binding_id]
else:
updated.pop(binding_id, None)
if self._uses_database(scope, owner_id) and owner_id is not None:
self._db().set_user_prompt(owner_id, "studio_function_bindings", json.dumps(updated, indent=2))
return updated
path = self._admin_bindings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(updated, indent=2) + "\n")
return updated
def delete_function_binding(self, scope: str, owner_id: Optional[int], binding_id: str) -> Dict[str, Dict[str, str]]:
bindings = self.list_function_bindings(scope, owner_id)
if binding_id not in bindings:
return bindings
updated = dict(bindings)
updated.pop(binding_id, None)
if self._uses_database(scope, owner_id) and owner_id is not None:
self._db().set_user_prompt(owner_id, "studio_function_bindings", json.dumps(updated, indent=2))
return updated
path = self._admin_bindings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(updated, indent=2) + "\n")
return updated
def delete_character(self, scope: str, owner_id: Optional[int], name: str) -> bool:
if self._uses_database(scope, owner_id):
return self._db().delete_studio_asset(owner_id, "character", name)
return self._delete_item(self.characters_dir, scope, owner_id, name)
def delete_environment(self, scope: str, owner_id: Optional[int], name: str) -> bool:
if self._uses_database(scope, owner_id):
return self._db().delete_studio_asset(owner_id, "environment", name)
return self._delete_item(self.environments_dir, scope, owner_id, name)
def delete_voice(self, scope: str, owner_id: Optional[int], name: str) -> bool:
if self._uses_database(scope, owner_id):
return self._db().delete_studio_asset(owner_id, "voice", name)
return self._delete_item(self.voices_dir, scope, owner_id, name)
def get_character_thumbnail_bytes(self, scope: str, owner_id: Optional[int], name: str) -> Optional[bytes]:
item = self.get_character(scope, owner_id, name) or {}
ref_images = item.get("ref_images", [])
if not ref_images:
return None
first = ref_images[0]
if self._uses_database(scope, owner_id):
if isinstance(first, str) and "," in first:
try:
return base64.b64decode(first.split(",", 1)[1])
except Exception:
return None
return None
item_dir = self._item_dir(self.characters_dir, scope, owner_id, name)
path = item_dir / first
return path.read_bytes() if path.exists() else None
def get_environment_thumbnail_bytes(self, scope: str, owner_id: Optional[int], name: str) -> Optional[bytes]:
item = self.get_environment(scope, owner_id, name) or {}
ref_images = item.get("ref_images", [])
if not ref_images:
return None
first = ref_images[0]
if self._uses_database(scope, owner_id):
if isinstance(first, str) and "," in first:
try:
return base64.b64decode(first.split(",", 1)[1])
except Exception:
return None
return None
item_dir = self._item_dir(self.environments_dir, scope, owner_id, name)
path = item_dir / first
return path.read_bytes() if path.exists() else None
def _normalize_profile_item(self, item: Optional[Dict[str, Any]], kind: str) -> Optional[Dict[str, Any]]:
if not item:
return None
normalized = dict(item)
refs = list(normalized.get("ref_images") or [])
normalized.setdefault("kind", kind)
normalized.setdefault("image_count", len(refs))
normalized["images"] = [
{
"label": f"ref{index}",
"data": ref,
}
for index, ref in enumerate(refs)
if isinstance(ref, str)
]
return normalized
def run_pipeline(self, payload: Dict[str, Any]) -> Dict[str, Any]:
steps = self._normalize_pipeline_steps(payload.get("steps") or [])
seed_input = payload.get("input") or payload.get("story") or ""
seed_story = payload.get("story") or payload.get("input") or ""
api_base = str(payload.get("api_base") or payload.get("_api_base") or "").rstrip("/")
if not api_base:
raise ValueError("Pipeline execution requires an API base path")
results: List[Dict[str, Any]] = []
async def _runner():
for index, step in enumerate(steps):
context = self._pipeline_context(seed_input, seed_story, results)
try:
step_result = await self._execute_pipeline_step(api_base, step, context)
step_result["step"] = index
except Exception as exc:
results.append({
"step": index,
"type": step.get("type", "step"),
"label": step.get("label") or step.get("type", f"step-{index}"),
"error": str(exc),
})
break
else:
results.append(step_result)
asyncio.run(_runner())
return {"steps": results}
def pipeline_step_types(self) -> List[Dict[str, Any]]:
return [
{"type": "chat", "label": "Chat", "params": [["model", "text", "Model", ""], ["prompt", "textarea", "Prompt", "{{input}}"]]},
{"type": "img-gen", "label": "Image generate", "params": [["model", "text", "Model", ""], ["prompt", "textarea", "Prompt", "{{input}}"], ["size", "text", "Size", "1024x1024"]]},
{"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": "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}}"]]},
{"type": "aud-clone", "label": "Voice clone", "params": [["model", "text", "Model", ""], ["input", "textarea", "Input text", "{{input}}"], ["reference_audio", "ref", "Reference audio", "{{step0.url}}"], ["ref_text", "textarea", "Reference transcript", ""]]},
{"type": "aud-convert", "label": "Voice convert", "params": [["model", "text", "Model", ""], ["audio", "ref", "Audio ref", "{{input}}"], ["target_voice", "ref", "Target voice", "{{step0.url}}"]]},
{"type": "embed", "label": "Embeddings", "params": [["model", "text", "Model", ""], ["input", "textarea", "Input", "{{input}}"]]},
{"type": "3d-generate", "label": "3D generate", "params": [["model", "text", "Model", ""], ["prompt", "textarea", "Prompt", "{{input}}"]]},
{"type": "img-to3d", "label": "Image to 3D", "params": [["model", "text", "Model", ""], ["image", "ref", "Image ref", "{{input}}"], ["prompt", "textarea", "Prompt", ""]]},
{"type": "img-from3d", "label": "3D to image", "params": [["model", "text", "Model", ""], ["scene", "ref", "3D ref", "{{input}}"], ["prompt", "textarea", "Prompt", ""]]},
]
def get_cached_models(self) -> Dict[str, Any]:
return {"hf": [], "gguf": []}
def get_admin_tokens(self) -> List[Dict[str, Any]]:
return []
studio_service = StudioService()
You are AiSBF, a general assistant for the AISBF Studio interface.
You help users brainstorm, plan, explain, and execute creative or technical work across chat, image, audio, video, profiles, and pipelines.
Guidelines:
- Be clear, practical, and concise.
- Prefer actionable answers over abstract theory.
- When the user is working inside Studio, adapt suggestions to the currently selected model and available Studio tools.
- If a requested action depends on unsupported or unavailable capabilities, say so plainly and suggest the closest supported workflow.
- Preserve user intent, constraints, and style preferences across the conversation.
- For creative tasks, be collaborative and generative without becoming verbose.
- For technical tasks, be precise and structured.
When responding:
- Answer directly.
- Ask only the minimum necessary clarification if the request is too ambiguous to complete well.
- If the user wants content to reuse in another Studio panel, format it so it can be copied easily.
......@@ -163,6 +163,8 @@ setup(
('share/aisbf/aisbf', [
'aisbf/__init__.py',
'aisbf/studio.py',
'aisbf/studio_adapters.py',
'aisbf/studio_services.py',
'aisbf/config.py',
'aisbf/models.py',
'aisbf/handlers.py',
......
......@@ -2,169 +2,442 @@
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
*
* AISBF - AI Service Broker Framework || AI Should Be Free
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
.studio-shell {
--studio-border: color-mix(in srgb, var(--color-border) 70%, transparent);
--studio-accent-soft: color-mix(in srgb, var(--color-primary) 18%, var(--bg-panel));
--studio-panel-bg: linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 92%, white 8%), color-mix(in srgb, var(--bg-page) 82%, var(--bg-panel)));
display: grid;
gap: 24px;
}
.studio-hero {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(260px, 1fr);
gap: 20px;
align-items: stretch;
.studio {
--text-1: var(--color-text);
--text-2: color-mix(in srgb, var(--color-text) 72%, transparent);
--text-3: color-mix(in srgb, var(--color-text) 45%, transparent);
--surface-0: color-mix(in srgb, var(--bg-page) 78%, black 22%);
--surface-1: color-mix(in srgb, var(--bg-panel) 92%, black 8%);
--surface-2: color-mix(in srgb, var(--bg-input) 82%, var(--bg-panel));
--surface-3: color-mix(in srgb, var(--bg-accent) 74%, black 26%);
--accent: var(--color-primary);
--accent-dim: color-mix(in srgb, var(--color-primary) 18%, transparent);
--border: color-mix(in srgb, var(--color-border) 72%, transparent);
--scroll-track: color-mix(in srgb, var(--bg-input) 72%, var(--bg-page) 28%);
--scroll-thumb: color-mix(in srgb, var(--color-primary) 38%, var(--color-border) 34%, var(--bg-panel) 28%);
--scroll-thumb-hover: color-mix(in srgb, var(--color-primary) 56%, var(--color-border) 24%, var(--bg-panel) 20%);
--scroll-thumb-active: color-mix(in srgb, var(--color-primary) 68%, var(--bg-panel) 32%);
--red: #f87171;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.studio-eyebrow {
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.75rem;
color: var(--color-link);
margin-bottom: 12px;
.studio,
.studio .model-list,
.studio .chat-msgs,
.studio .gen-ctrl,
.studio .gen-out,
.studio .pipe-panel,
.studio .tabbar1,
.studio .tabbar2 {
scrollbar-width: thin;
scrollbar-color: var(--scroll-thumb) var(--scroll-track);
}
.studio-title {
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1;
margin-bottom: 14px;
.studio *::-webkit-scrollbar {
width: 11px;
height: 11px;
}
.studio-subtitle,
.studio-copy {
color: var(--color-muted);
line-height: 1.6;
.studio *::-webkit-scrollbar-track {
background: var(--scroll-track);
border-radius: 999px;
}
.studio-status-card,
.studio-panel {
background: var(--studio-panel-bg);
border: 1px solid var(--studio-border);
border-radius: 20px;
box-shadow: 0 20px 45px color-mix(in srgb, var(--shadow-lg) 70%, transparent);
.studio *::-webkit-scrollbar-thumb {
background: var(--scroll-thumb);
border-radius: 999px;
border: 2px solid var(--scroll-track);
background-clip: padding-box;
}
.studio-status-card {
padding: 24px;
display: grid;
align-content: space-between;
gap: 16px;
.studio *::-webkit-scrollbar-thumb:hover {
background: var(--scroll-thumb-hover);
border: 2px solid var(--scroll-track);
background-clip: padding-box;
}
.studio-status-label,
.studio-chip {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.12em;
.studio *::-webkit-scrollbar-thumb:active {
background: var(--scroll-thumb-active);
border: 2px solid var(--scroll-track);
background-clip: padding-box;
}
.studio-status-label {
color: var(--color-subtle);
.studio *::-webkit-scrollbar-corner {
background: var(--scroll-track);
}
/* ── Layout ─────────────────────────────────────────────────────── */
.studio { display:flex; height:calc(100vh - 56px); overflow:hidden; }
.studio-status-value {
font-size: 1.1rem;
line-height: 1.4;
/* Sidebar */
.sidebar {
width:220px; min-width:180px; background:var(--surface-1);
border-right:1px solid var(--border); display:flex; flex-direction:column;
overflow:hidden; flex-shrink:0;
}
.studio-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
gap: 20px;
.sidebar-hd { padding:.6rem 1rem .15rem; font-size:10px; font-weight:700;
color:var(--text-3); letter-spacing:.07em; text-transform:uppercase; }
.model-list { flex:1; overflow-y:auto; padding:.2rem .4rem .5rem; }
.model-item {
display:flex; align-items:center; gap:.4rem; padding:.4rem .55rem;
border-radius:6px; cursor:pointer; font-size:12px; color:var(--text-2);
transition:background .1s;
}
.studio-panel {
padding: 24px;
.binding-list { display:flex; flex-direction:column; gap:.5rem; }
.binding-card { border:1px solid var(--border); border-radius:8px; background:var(--surface-0); overflow:hidden; }
.binding-card.active { border-color:color-mix(in srgb, var(--accent) 50%, var(--border)); box-shadow:0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); }
.binding-card-head { padding:.55rem .65rem; display:flex; align-items:flex-start; justify-content:space-between; gap:.5rem; cursor:pointer; }
.binding-card-title { font-size:12px; font-weight:600; color:var(--text-1); }
.binding-card-meta { font-size:10px; color:var(--text-3); margin-top:.12rem; }
.binding-card-count { font-size:10px; color:var(--text-2); background:var(--surface-2); border:1px solid var(--border); border-radius:999px; padding:.12rem .45rem; white-space:nowrap; }
.binding-card-body { padding:0 .65rem .65rem; border-top:1px solid var(--border); display:flex; flex-direction:column; gap:.45rem; }
.binding-role { background:var(--surface-1); border:1px solid var(--border); border-radius:7px; padding:.45rem; display:flex; flex-direction:column; gap:.35rem; }
.binding-role-top { display:flex; align-items:center; justify-content:space-between; gap:.4rem; }
.binding-role-label { font-size:11px; font-weight:600; color:var(--text-1); }
.binding-role-state { font-size:10px; color:var(--text-3); }
.binding-role-meta { font-size:10px; color:var(--text-3); }
.binding-role-search { width:100%; }
.binding-role-results { display:flex; flex-direction:column; gap:.25rem; max-height:210px; overflow-y:auto; }
.binding-role-result { display:flex; align-items:flex-start; justify-content:space-between; gap:.45rem; width:100%; border:1px solid var(--border); border-radius:6px; background:var(--surface-2); color:var(--text-2); padding:.4rem .45rem; cursor:pointer; font-family:inherit; text-align:left; }
.binding-role-result:hover { background:var(--surface-3); color:var(--text-1); }
.binding-role-result.active { border-color:color-mix(in srgb, var(--accent) 55%, var(--border)); background:var(--accent-dim); color:var(--text-1); }
.binding-role-result-name { font-size:11px; font-weight:600; }
.binding-role-result-meta { font-size:10px; color:var(--text-3); margin-top:.08rem; }
.binding-role-clear { align-self:flex-start; }
.binding-empty { font-size:11px; color:var(--text-3); padding:.3rem 0; }
.model-item:hover { background:var(--surface-2); }
.model-item.active { background:var(--accent-dim,#1d3354); color:var(--accent,#4e9cf5); font-weight:500; }
.mbadge {
font-size:9px; font-weight:700; padding:1px 5px; border-radius:20px;
letter-spacing:.03em; text-transform:uppercase; flex-shrink:0;
}
.mb-text { background:#1d3250; color:#7aaef7; }
.mb-vision { background:#1a2e3a; color:#5ed3f5; }
.mb-image { background:#1d3520; color:#6ecf7e; }
.mb-video { background:#301a40; color:#c07af5; }
.mb-audio { background:#3a2510; color:#f0a844; }
.mb-tts { background:#2d2010; color:#f0c060; }
.mb-audiogen{ background:#1a2535; color:#70b8f5; }
.mb-embed { background:#1e2e1e; color:#88c888; }
.studio-panel-emphasis {
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--color-link) 20%, transparent), transparent 35%),
var(--studio-panel-bg);
}
/* Main */
.studio-main { flex:1; display:flex; flex-direction:column; overflow:hidden; }
.studio-panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 18px;
/* Two-level tab bar */
.tabbar1 {
display:flex; gap:.2rem; padding:.45rem .6rem .3rem;
border-bottom:1px solid var(--border); background:var(--surface-0); flex-shrink:0;
overflow-x:auto;
}
.studio-chip {
padding: 6px 10px;
border-radius: 999px;
background: var(--studio-accent-soft);
color: var(--color-text);
.tabbar2 {
display:none; gap:.15rem; padding:.3rem .6rem;
border-bottom:1px solid var(--border); background:var(--surface-1); flex-shrink:0;
overflow-x:auto;
}
.studio-placeholder-list {
display: grid;
gap: 12px;
margin-top: 20px;
.tabbar2.visible { display:flex; }
.t1btn, .t2btn {
padding:.28rem .65rem; border-radius:5px; font-size:12px; font-weight:500;
cursor:pointer; border:1px solid transparent; color:var(--text-3);
background:transparent; transition:all .1s; white-space:nowrap; flex-shrink:0;
display:inline-flex; align-items:center; gap:.35rem;
}
.studio-placeholder-item {
height: 72px;
border-radius: 16px;
background:
linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-link) 18%, transparent), transparent),
color-mix(in srgb, var(--bg-input) 86%, var(--bg-panel));
background-size: 220% 100%;
animation: studio-sheen 2.8s linear infinite;
border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
.t1btn:hover, .t2btn:hover { background:var(--surface-2); color:var(--text-1); }
.t1btn.active { background:var(--accent,#4e9cf5); color:#fff; }
.t2btn.active { background:var(--surface-3,#333); color:var(--text-1); border-color:var(--border); }
.t1btn.hidden, .t2btn.hidden { display:none; }
.t1btn.state-partial, .t2btn.state-partial { color:var(--text-2); }
.t1btn.state-unavailable, .t2btn.state-unavailable { color:var(--text-3); opacity:.55; }
.t1btn.state-unavailable:hover, .t2btn.state-unavailable:hover { color:var(--text-2); }
.tab-status {
display:inline-flex; align-items:center; padding:1px 5px; border-radius:999px;
font-size:9px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
background:var(--surface-3,#333); color:var(--text-2);
}
.t1btn.state-ready .tab-status, .t2btn.state-ready .tab-status { background:#0d2e18; color:#4ade80; }
.t1btn.state-partial .tab-status, .t2btn.state-partial .tab-status { background:#3a2510; color:#f0c060; }
.t1btn.state-unavailable .tab-status, .t2btn.state-unavailable .tab-status { background:var(--surface-2); color:var(--text-3); }
.t1btn.active .tab-status { background:rgba(255,255,255,.18); color:#fff; }
.t2btn.active .tab-status { background:rgba(255,255,255,.08); color:var(--text-1); }
.state-hidden { display:none !important; }
/* Panels */
.panel { flex:1; display:none; flex-direction:column; overflow:hidden; }
.panel.active { display:flex; }
.studio-diagnostics {
margin-top: 20px;
min-height: 164px;
padding: 18px;
border-radius: 16px;
background: color-mix(in srgb, var(--bg-input) 86%, var(--bg-panel));
border: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent);
color: var(--color-subtle);
/* ── Chat ─────────────────────────────────────────────────────── */
.chat-msgs { flex:1; overflow-y:auto; padding:1rem 1.25rem; display:flex; flex-direction:column; gap:.75rem; }
.chat-empty { margin:auto; text-align:center; color:var(--text-3); }
.chat-empty h3 { font-size:1rem; margin-bottom:.25rem; }
.msg { display:flex; gap:.75rem; }
.msg.user { flex-direction:row-reverse; }
.av { width:28px; height:28px; border-radius:50%; display:flex; align-items:center;
justify-content:center; font-size:9px; font-weight:700; flex-shrink:0; }
.av.user { background:var(--accent,#4e9cf5); color:#fff; }
.av.ai { background:var(--surface-3,#2a2a2a); color:var(--text-2); }
.msg-body { max-width:70%; }
.msg.user .msg-body { text-align:right; }
.msg-meta { font-size:10px; color:var(--text-3); margin-bottom:.2rem; }
.msg-text { background:var(--surface-2); padding:.5rem .75rem; border-radius:8px;
font-size:13px; line-height:1.55; white-space:pre-wrap; word-break:break-word; }
.msg.user .msg-text { background:var(--accent-dim,#1d3354); }
.msg-img { max-width:280px; border-radius:8px; margin-top:.3rem; cursor:pointer; }
.chat-foot { flex-shrink:0; padding:.5rem .75rem .75rem; border-top:1px solid var(--border); }
.attach-bar { display:flex; align-items:center; gap:.5rem; margin-bottom:.3rem; }
.attach-thumb { width:36px; height:36px; border-radius:4px; object-fit:cover; }
.chat-row { display:flex; gap:.5rem; align-items:flex-end; }
.chat-ta { flex:1; resize:none; border-radius:6px; border:1px solid var(--border);
background:var(--surface-2); color:var(--text-1); padding:.45rem .7rem;
font-size:13px; font-family:inherit; outline:none; min-height:36px; max-height:140px; }
.chat-hint { font-size:10px; color:var(--text-3); margin-top:.2rem; text-align:right; }
/* ── Shared panel helpers ─────────────────────────────────────── */
.gen-wrap { flex:1; display:flex; overflow:hidden; }
.gen-ctrl { width:min(380px,36vw); min-width:340px; max-width:420px; padding:.9rem 1rem; overflow-y:auto; border-right:1px solid var(--border);
background:var(--surface-1); display:flex; flex-direction:column; gap:.65rem; flex-shrink:0; }
/* spacer so Chrome doesn't clip the last child in overflow-y:auto */
.gen-ctrl::after { content:''; display:block; min-height:.75rem; }
.gen-out { flex:1; display:flex; align-items:center; justify-content:center;
overflow:auto; padding:1.2rem; background:var(--surface-0); }
.gen-out-inner { display:flex; flex-direction:column; align-items:center; gap:.6rem; width:100%; max-width:960px; }
.gen-empty { color:var(--text-3); text-align:center; font-size:13px; }
.out-img { max-width:100%; max-height:calc(100vh - 200px); border-radius:8px; cursor:pointer; }
.out-video { max-width:100%; max-height:calc(100vh - 200px); border-radius:8px; }
.out-audio { width:100%; }
.fl { font-size:11px; font-weight:600; color:var(--text-2); margin-bottom:.15rem; display:block; }
.progress { min-height:18px; }
.progress:not(:empty) {
display:block; font-size:13px; font-weight:600; letter-spacing:.03em;
color:#c7caf5;
border-left:3px solid #6366f1;
padding:.3rem .55rem;
background:rgba(99,102,241,.1);
border-radius:0 5px 5px 0;
margin-top:.4rem;
overflow:visible;
animation:prog-pulse 1.6s ease-in-out infinite;
}
@keyframes prog-pulse { 0%,100%{opacity:1;border-color:#6366f1} 50%{opacity:.7;border-color:#a5b4fc} }
.gen-progress-wrap { display:none; margin-top:.4rem; }
.gen-progress-wrap.active { display:block; }
.gen-progress-bar-bg {
height:6px; border-radius:3px; background:var(--surface-3);
overflow:hidden; margin-top:.3rem;
}
.gen-progress-bar-fill {
height:100%; border-radius:3px;
background:linear-gradient(90deg,#6366f1,#a5b4fc);
transition:width .4s ease;
width:0%;
}
.gen-progress-label {
font-size:11px; color:#a0a8cc; margin-top:.15rem; text-align:right;
}
.fi, .fs, .fselect { background:var(--surface-2); border:1px solid var(--border); border-radius:5px;
color:var(--text-1); padding:.38rem .6rem; font-size:13px; font-family:inherit;
outline:none; width:100%; box-sizing:border-box; color-scheme:dark; }
.fs { resize:vertical; min-height:70px; }
.fselect { appearance:auto; }
.fselect option, .fselect optgroup { background:var(--surface-2); color:var(--text-1); }
.g2 { display:grid; grid-template-columns:1fr 1fr; gap:.5rem; }
.g3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:.5rem; }
.frow { display:flex; flex-direction:column; gap:.15rem; }
.char-refs { display:flex; flex-wrap:wrap; gap:.3rem; margin-top:.25rem; }
.char-thumb { width:44px; height:44px; object-fit:cover; border-radius:4px; cursor:pointer; }
.char-thumb:hover { opacity:.7; }
.char-slot { border:1px solid var(--border); border-radius:6px; padding:.5rem .6rem; display:flex; flex-direction:column; gap:.35rem; background:var(--surface-1); }
.char-slot-header { display:flex; gap:.4rem; align-items:center; }
.char-slot-actions { display:flex; gap:.3rem; align-items:center; flex-wrap:wrap; }
.media-input-group { display:flex; gap:.4rem; align-items:center; flex:1; }
.media-preview img, .media-preview video, .media-preview audio { max-width:100%; max-height:120px; border-radius:6px; margin-top:.3rem; }
.webcam-box { margin-top:.4rem; display:flex; flex-direction:column; gap:.4rem; }
.webcam-box video { width:100%; max-height:200px; border-radius:6px; background:#000; }
.webcam-box .webcam-controls { display:flex; gap:.4rem; flex-wrap:wrap; }
/* download link styled as button */
a.dl { display:inline-block; margin-top:.4rem; }
@keyframes studio-sheen {
0% { background-position: 200% 0; }
100% { background-position: -20% 0; }
@media (max-width: 900px) {
.gen-ctrl { width:min(360px,44vw); min-width:280px; }
}
@media (max-width: 980px) {
.studio-hero,
.studio-grid {
grid-template-columns: 1fr;
}
@media (max-width: 720px) {
.gen-wrap { flex-direction:column; }
.gen-ctrl { width:100%; min-width:0; max-width:none; border-right:none; border-bottom:1px solid var(--border); max-height:55vh; }
}
@media (max-width: 768px) {
.studio-shell {
gap: 16px;
}
/* ── Capability cards ─────────────────────────────────────────── */
.cap-card {
border:1px solid var(--border); background:var(--surface-1); border-radius:8px;
padding:.75rem .9rem; margin-bottom:.75rem; display:flex; flex-direction:column; gap:.55rem;
}
.cap-card.state-partial {
border-color:rgba(245,158,11,.4); background:rgba(58,37,16,.7);
}
.cap-card.state-unavailable {
border-color:rgba(248,113,113,.4); background:rgba(80,28,28,.7);
}
.cap-card-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem; }
.cap-card-title { font-size:13px; font-weight:600; color:var(--text-1); }
.cap-card-summary { font-size:12px; line-height:1.45; color:var(--text-2); }
.cap-meta { display:flex; flex-wrap:wrap; gap:.35rem; }
.cap-chip {
font-size:10px; border-radius:999px; padding:.16rem .45rem;
background:var(--surface-2); color:var(--text-2); border:1px solid var(--border);
}
.cap-chip.ok { background:#0d2e18; color:#4ade80; border-color:transparent; }
.cap-chip.warn { background:#3a2510; color:#f0c060; border-color:transparent; }
.cap-chip.dim { opacity:.72; }
.cap-missing, .cap-note { font-size:12px; color:var(--text-2); }
.cap-note strong, .cap-missing strong { color:var(--text-1); }
.cap-find-link { display:inline-flex; align-items:center; gap:2px; text-decoration:none; border-radius:4px; transition:opacity .15s; }
.cap-find-link:hover { opacity:.75; }
.cap-find-icon { font-size:10px; color:var(--text-3); }
.cap-output-note {
width:100%; max-width:960px; border:1px solid rgba(245,158,11,.35); background:rgba(58,37,16,.85);
color:#f6d08a; border-radius:8px; padding:.85rem 1rem; display:flex; flex-direction:column; gap:.4rem;
font-size:13px; margin-bottom:.75rem;
}
.cap-output-note.unavailable {
border-color:rgba(248,113,113,.35); background:rgba(80,28,28,.85); color:#f3b2b2;
}
.cap-output-note ul { margin:0; padding-left:1.1rem; }
.cap-toggle { font-size:12px; display:flex; align-items:center; gap:.4rem; cursor:pointer; color:var(--text-2); }
.cap-toggle input { margin:0; }
.cap-preserve-note {
font-size:12px; line-height:1.45; color:var(--text-2); background:var(--surface-2);
border:1px solid var(--border); border-radius:6px; padding:.55rem .65rem;
}
.req-preview {
border:1px solid var(--border); border-radius:8px; background:var(--surface-2);
padding:.7rem; display:flex; flex-direction:column; gap:.55rem;
}
.req-preview-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.6rem; }
.req-preview-title { font-size:12px; font-weight:600; color:var(--text-1); }
.req-preview-sub { font-size:11px; color:var(--text-3); margin-top:.15rem; }
.req-preview-endpoint { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-all; }
.req-preview-grid { display:grid; grid-template-columns:1fr 1fr; gap:.45rem; }
.req-preview-field { border:1px solid var(--border); border-radius:6px; background:var(--surface-1); padding:.45rem .5rem; min-width:0; }
.req-preview-label { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-3); margin-bottom:.15rem; }
.req-preview-value { font-size:12px; color:var(--text-1); word-break:break-word; }
.req-preview-code { width:100%; box-sizing:border-box; min-height:180px; resize:vertical; font-family:var(--mono); font-size:11px; }
.req-preview-actions { display:flex; gap:.4rem; flex-wrap:wrap; align-items:center; }
.req-preview-status { font-size:11px; color:var(--text-3); min-height:14px; }
.studio-panel,
.studio-status-card {
padding: 18px;
border-radius: 16px;
}
/* ── Diagnostics / history ────────────────────────────────────── */
.diag-card, .hist-card {
border:1px solid var(--border); background:var(--surface-1); border-radius:8px;
padding:.8rem .9rem; display:flex; flex-direction:column; gap:.6rem; margin-bottom:.6rem;
}
.diag-title, .hist-title { font-size:13px; font-weight:600; color:var(--text-1); }
.diag-sub, .hist-empty, .hist-meta, .hist-summary { font-size:12px; color:var(--text-2); line-height:1.45; }
.diag-groups, .hist-list { display:flex; flex-direction:column; gap:.5rem; }
.diag-group { display:flex; flex-direction:column; gap:.3rem; }
.diag-group-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem; }
.diag-group-label { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-3); }
.diag-items, .hist-links, .hist-chips { display:flex; flex-wrap:wrap; gap:.35rem; }
.diag-empty { font-size:11px; color:var(--text-3); }
.hist-item { border:1px solid var(--border); border-radius:7px; background:var(--surface-2); padding:.65rem .7rem; display:flex; flex-direction:column; gap:.35rem; }
.hist-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.6rem; }
.hist-name { font-size:12px; font-weight:600; color:var(--text-1); }
.hist-link { font-size:11px; }
.studio-panel-header {
align-items: flex-start;
flex-direction: column;
}
/* ── Pipeline ─────────────────────────────────────────────────── */
.pipe-panel { flex:1; overflow-y:auto; padding:1.2rem; display:block; }
.pipe-card { background:var(--surface-1); border:1px solid var(--border); border-radius:8px; overflow:hidden; margin-bottom:.6rem; }
.pipe-card summary {
list-style:none; padding:.8rem 1rem; cursor:pointer; display:flex; align-items:flex-start;
justify-content:space-between; gap:.75rem; font-weight:600; font-size:13px; user-select:none; color:var(--text-1);
}
.pipe-card summary::-webkit-details-marker { display:none; }
.pipe-card summary::after { content:'›'; font-size:16px; color:var(--text-2); transition:transform .2s; margin-top:.1rem; }
.pipe-card[open] summary::after { transform:rotate(90deg); }
.pipe-card summary:hover { background:rgba(255,255,255,.03); }
.pipe-card-body { padding:.75rem 1rem 1rem; border-top:1px solid var(--border); display:flex; flex-direction:column; gap:.5rem; }
.pipe-head { display:flex; flex-direction:column; gap:.45rem; min-width:0; flex:1; }
.pipe-title { font-weight:600; font-size:13px; color:var(--text-1); }
.pipe-summary { font-size:12px; color:var(--text-2); line-height:1.45; }
.pipe-tags { display:flex; flex-wrap:wrap; gap:.35rem; }
.pipe-tag { font-size:10px; padding:.16rem .45rem; border-radius:999px; background:var(--surface-2); color:var(--text-2); border:1px solid var(--border); }
.pipe-badge { font-size:10px; padding:.15rem .45rem; border-radius:999px; font-weight:600; align-self:flex-start; }
.pipe-badge.ready { background:#0f2a0f; color:#4caf50; border:1px solid #2a5a2a; }
.pipe-badge.partial { background:#2a1f0a; color:#f0a020; border:1px solid #5a3a10; }
.pipe-badge.unavailable { background:var(--surface-2); color:var(--text-3); border:1px solid var(--border); }
.pipe-steps { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; font-size:11px; color:var(--text-2); margin-bottom:.1rem; }
.pipe-step { background:var(--surface-2); border-radius:5px; padding:.2rem .5rem; color:var(--text-2); }
.pipe-arrow { color:var(--text-3); }
.pb-step { background:var(--surface-2); border:1px solid var(--border); border-radius:6px; padding:.5rem .6rem; display:flex; flex-direction:column; gap:.3rem; }
.pb-step-header { display:flex; align-items:center; gap:.4rem; font-size:12px; font-weight:600; }
.pb-step-params { display:flex; flex-direction:column; gap:.25rem; padding-top:.25rem; }
.pb-step-param { display:flex; align-items:center; gap:.4rem; font-size:12px; }
/* ── Pipeline capability chips ────────────────────────────────── */
.pipe-caps { display:flex; flex-wrap:wrap; align-items:center; gap:.3rem; padding-bottom:.6rem; margin-bottom:.25rem; border-bottom:1px solid var(--border); }
.pipe-caps-label { font-size:10px; color:var(--text-3); text-transform:uppercase; letter-spacing:.05em; margin-right:.1rem; }
.pipe-cap-chip { font-size:10px; padding:.1rem .4rem; border-radius:4px; border:1px solid transparent; }
.pipe-cap-chip.ok { background:#0d2e18; color:#4ade80; border-color:#1a4a1a; }
.pipe-cap-chip.missing { background:#2e0d0d; color:#f07070; border-color:#5a1a1a; }
.pipe-cap-chip.optional { background:var(--surface-2); color:var(--text-3); }
.pipe-cap-chip.optional.ok { background:#1a1f0a; color:#c0d060; border-color:#3a4a10; }
/* ── Sub-tab model picker ─────────────────────────────────────── */
.cap-model-picker { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; padding-top:.4rem; border-top:1px solid var(--border); margin-top:.1rem; }
.cap-model-picker.multi { flex-direction:column; align-items:flex-start; gap:.4rem; }
.cap-model-picker-label { font-size:10px; color:var(--text-3); text-transform:uppercase; letter-spacing:.05em; white-space:nowrap; }
.cap-model-chips { display:flex; flex-wrap:wrap; gap:.3rem; }
.cap-assign-rows { display:flex; flex-direction:column; gap:.3rem; width:100%; }
.cap-assign-row { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
.cap-assign-label { font-size:10px; color:var(--text-2); min-width:7rem; flex-shrink:0; text-align:right; }
.cap-assign-row.opt .cap-assign-label { color:var(--text-3); }
.cap-assign-sep { font-size:10px; color:var(--text-3); text-transform:uppercase; letter-spacing:.05em; padding-top:.2rem; border-top:1px solid var(--border); width:100%; }
.cap-model-chip { font-size:11px; padding:.18rem .55rem; border-radius:999px; border:1px solid; cursor:pointer; background:transparent; font-family:inherit; transition:background .15s; }
.cap-model-chip.ok { color:#4ade80; border-color:#2a5a2a; background:#0f2a0f; }
.cap-model-chip.ok:hover { background:#1a3f1a; }
.cap-model-chip.warn { color:#f0c060; border-color:#5a3a10; background:#2a1f0a; }
.cap-model-chip.warn:hover { background:#3a2f0a; }
.cap-model-chip.active { font-weight:700; outline:1px solid currentColor; outline-offset:1px; }
/* ── Sidebar capability highlights ───────────────────────────── */
.model-item.cap-ok { border-left:3px solid #4caf50; padding-left:calc(.55rem - 3px); background:rgba(76,175,80,.13); }
.model-item.cap-ok:hover { background:rgba(76,175,80,.22); }
.model-item.cap-partial { border-left:3px solid #f0a020; padding-left:calc(.55rem - 3px); background:rgba(240,160,32,.11); }
.model-item.cap-partial:hover { background:rgba(240,160,32,.2); }
.pb-step-param label { min-width:110px; color:var(--text-2); flex-shrink:0; }
.pb-step-param input, .pb-step-param select, .pb-step-param textarea { flex:1; font-size:12px; }
.pb-step-param textarea { rows:2; resize:vertical; min-height:40px; }
/* ── Archive panel ────────────────────────────────────────────── */
.archive-panel { flex:1; overflow-y:auto; padding:1.2rem; display:flex; flex-direction:column; gap:.9rem; }
.archive-toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem; }
.archive-filters { display:flex; gap:.35rem; }
.arch-filter { background:var(--surface-2); border:1px solid var(--border); border-radius:5px; color:var(--text-2); padding:.28rem .7rem; font-size:12px; cursor:pointer; font-family:inherit; transition:background .15s; }
.arch-filter.active { background:var(--accent,#4e9cf5); color:#fff; border-color:var(--accent,#4e9cf5); }
.archive-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:.7rem; }
.arch-card { background:var(--surface-1); border:1px solid var(--border); border-radius:8px; overflow:hidden; display:flex; flex-direction:column; }
.arch-thumb { width:100%; aspect-ratio:1; object-fit:cover; background:var(--surface-2); display:block; cursor:pointer; }
.arch-thumb-ph { width:100%; aspect-ratio:1; background:var(--surface-2); display:flex; align-items:center; justify-content:center; font-size:2.2rem; color:var(--text-3); }
.arch-info { padding:.35rem .5rem; display:flex; flex-direction:column; gap:.15rem; }
.arch-name { font-size:11px; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.arch-meta { font-size:10px; color:var(--text-3); }
.arch-actions { display:flex; gap:.25rem; padding:.3rem .4rem; border-top:1px solid var(--border); flex-wrap:wrap; }
.arch-btn { flex:1; font-size:11px; padding:.22rem .35rem; border:1px solid var(--border); border-radius:4px; background:transparent; color:var(--text-2); cursor:pointer; font-family:inherit; text-decoration:none; text-align:center; white-space:nowrap; }
.arch-btn:hover { background:var(--surface-2); color:var(--text-1); }
.arch-btn.del:hover { background:#3a1010; color:#f07070; border-color:#5a2020; }
.arch-empty { color:var(--text-3); text-align:center; padding:2rem 1rem; grid-column:1/-1; font-size:13px; }
/* ── Profile panels ───────────────────────────────────────────── */
.prof-gen-out { align-items:flex-start; justify-content:flex-start; flex-direction:column; padding:0; }
.prof-out-hd { display:flex; align-items:center; justify-content:space-between; padding:.6rem 1rem .55rem; flex-shrink:0; border-bottom:1px solid var(--border); width:100%; box-sizing:border-box; }
.prof-out-hd-title { font-size:13px; font-weight:600; color:var(--text-1); }
.prof-out-body { flex:1; overflow-y:auto; padding:.8rem; width:100%; box-sizing:border-box; }
.prof-card-info { padding:.6rem .75rem .75rem; display:flex; flex-direction:column; gap:.15rem; flex:1; }
.prof-card-name { font-weight:600; font-size:13px; color:var(--text-1); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.prof-card-desc { font-size:11.5px; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.prof-card-meta { font-size:11px; color:var(--text-3); }
.prof-card-actions { display:flex; gap:.4rem; margin-top:.45rem; }
.prof-voice-list { display:flex; flex-direction:column; gap:.55rem; }
.prof-voice-card { display:flex; align-items:flex-start; gap:.85rem; background:var(--surface-1); border:1px solid var(--border); border-radius:8px; padding:.75rem .9rem; }
.prof-voice-icon { width:40px; height:40px; border-radius:8px; background:var(--surface-3); display:flex; align-items:center; justify-content:center; font-size:1.3rem; flex-shrink:0; }
.prof-voice-info { flex:1; min-width:0; }
.prof-voice-name { font-weight:600; font-size:13px; color:var(--text-1); }
.prof-voice-meta { font-size:11px; color:var(--text-3); margin-top:.15rem; }
.prof-voice-quote { font-style:italic; color:var(--text-3); font-size:11.5px; margin-top:.15rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.prof-voice-actions { display:flex; gap:.4rem; margin-top:.5rem; }
/* ── Role-picker popup ────────────────────────────────────────── */
.role-picker-popup { position:fixed; z-index:9999; background:var(--surface-1); border:1px solid var(--border); border-radius:8px; padding:.7rem; box-shadow:0 8px 24px rgba(0,0,0,.5); min-width:200px; max-width:280px; }
.role-picker-header { font-size:12px; color:var(--text-2); margin-bottom:.5rem; }
.role-picker-caps { display:flex; flex-direction:column; gap:.3rem; }
.role-pick-btn { background:var(--surface-2); border:1px solid var(--border); border-radius:5px; color:var(--text-1); padding:.35rem .6rem; font-size:12px; cursor:pointer; font-family:inherit; text-align:left; display:flex; align-items:center; justify-content:space-between; gap:.4rem; }
.role-pick-btn:hover { background:var(--surface-3); }
.role-pick-btn.active { border-color:#6366f1; background:rgba(99,102,241,.1); }
.role-pick-cap { text-transform:capitalize; }
.role-pick-badge { font-size:10px; color:#6366f1; }
.role-picker-close { width:100%; margin-top:.45rem; background:transparent; border:1px solid var(--border); border-radius:5px; color:var(--text-3); padding:.28rem; font-size:11px; cursor:pointer; font-family:inherit; }
.role-picker-close:hover { color:var(--text-1); background:var(--surface-2); }
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -831,9 +831,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="nav">
<a href="{{ url_for(request, '/dashboard') }}" {% if request.path == '/dashboard' %}class="active"{% endif %} data-i18n="nav.overview">Overview</a>
<a href="{{ url_for(request, '/dashboard/providers') }}" {% if '/providers' in request.path %}class="active"{% endif %} data-i18n="nav.providers">Providers</a>
<a href="{{ url_for(request, '/dashboard/studio') }}" {% if '/studio' in request.path %}class="active"{% endif %} data-i18n="nav.studio">Studio</a>
<a href="{{ url_for(request, '/dashboard/rotations') }}" {% if '/rotations' in request.path %}class="active"{% endif %} data-i18n="nav.rotations">Rotations</a>
<a href="{{ url_for(request, '/dashboard/autoselect') }}" {% if '/autoselect' in request.path %}class="active"{% endif %} data-i18n="nav.autoselect">Autoselect</a>
<a href="{{ url_for(request, '/dashboard/studio') }}" {% if '/studio' in request.path %}class="active"{% endif %} data-i18n="nav.studio">Studio</a>
<a href="{{ url_for(request, '/dashboard/prompts') }}" {% if '/prompts' in request.path %}class="active"{% endif %} data-i18n="nav.prompts">Prompts</a>
<a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %} data-i18n="nav.analytics">Analytics</a>
{% if request.session.user_id %}
......
......@@ -350,6 +350,14 @@ function renderAutoselectDetails(autoselectKey) {
const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey];
const safeAKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const inheritedCaps = Array.isArray(autoselect.capabilities) ? autoselect.capabilities : [];
const partialCaps = Array.isArray(autoselect.partial_capabilities) ? autoselect.partial_capabilities : [];
const inheritedCapsHtml = inheritedCaps.length
? inheritedCaps.map(cap => `<span style="display:inline-block;margin:2px 6px 0 0;padding:3px 8px;border-radius:999px;background:var(--bg-accent);font-size:12px;">${escHtmlAttr(cap)}</span>`).join('')
: '<span style="color: var(--color-muted);">No capability is currently shared by every available model in this autoselection.</span>';
const partialCapsHtml = partialCaps.length
? `<div style="margin-top:8px;font-size:12px;color:var(--color-muted);">Partial across only some models: ${partialCaps.map(cap => escHtmlAttr(cap)).join(', ')}</div>`
: '';
// Default to "internal" if selection_model is not set
const selectionValue = autoselect.selection_model || 'internal';
......@@ -388,6 +396,9 @@ function renderAutoselectDetails(autoselectKey) {
<div class="form-group">
<label>${window.i18n.t('autoselect.capabilities')}</label>
<input type="text" value="${autoselect.capabilities ? autoselect.capabilities.join(', ') : ''}" onchange="updateAutoselectCapabilities('${autoselectKey}', this.value)" placeholder="${window.i18n.t('autoselect.capabilities_placeholder')}">
<small style="color: var(--color-muted); display:block; margin-top:5px;">This field is auto-inherited from the capabilities shared by all available models when you save.</small>
<div style="margin-top:8px;">${inheritedCapsHtml}</div>
${partialCapsHtml}
</div>
<div class="form-group">
......
......@@ -180,6 +180,9 @@ const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }};
// Marker used by the AISBF Chrome Extension to auto-detect this page and configure itself.
window.AISBF_PROVIDERS_PAGE = { serverUrl: window.location.origin + BASE_PATH };
let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }};
const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices_json | safe }};
const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_choices_json | safe }};
const STUDIO_ADAPTER_PROFILE_CHOICES = {{ studio_adapter_profile_choices_json | safe }};
let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
......@@ -1359,6 +1362,22 @@ function renderModels(providerKey) {
container.innerHTML = '';
provider.models.forEach((model, index) => {
const selectedStudioCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
const inferredCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
const studioCapabilityOptions = STUDIO_CAPABILITY_CHOICES.map(cap => {
const selected = selectedStudioCaps.includes(cap) ? 'selected' : '';
return `<option value="${escHtmlAttr(cap)}" ${selected}>${escHtmlAttr(cap)}</option>`;
}).join('');
const capabilitySource = model.studio_capability_source || 'heuristic';
const selectedAdapter = model.studio_adapter_override || 'auto';
const effectiveAdapter = model.studio_adapter || 'auto';
const adapterOptions = STUDIO_ADAPTER_CHOICES.map(adapter => `<option value="${escHtmlAttr(adapter)}" ${selectedAdapter === adapter ? 'selected' : ''}>${escHtmlAttr(adapter)}</option>`).join('');
const selectedAdapterProfile = model.studio_adapter_profile_override || 'auto';
const effectiveAdapterProfile = model.studio_adapter_profile || 'auto';
const adapterProfileOptions = STUDIO_ADAPTER_PROFILE_CHOICES.map(profile => `<option value="${escHtmlAttr(profile)}" ${selectedAdapterProfile === profile ? 'selected' : ''}>${escHtmlAttr(profile)}</option>`).join('');
const capabilityNotes = Array.isArray(model.studio_capability_notes) && model.studio_capability_notes.length
? `<div style="margin-top:6px;color:var(--color-muted);font-size:12px;">${model.studio_capability_notes.map(note => escHtmlAttr(note)).join(' | ')}</div>`
: '';
const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid var(--color-border); padding: 15px; margin-bottom: 10px; border-radius: 3px; background: var(--bg-page);';
......@@ -1426,6 +1445,35 @@ function renderModels(providerKey) {
Privacy
</label>
</div>
<div class="form-group">
<label>Studio capabilities</label>
<select multiple size="8" onchange="updateModelStudioCapabilities('${providerKey}', ${index}, this)" style="width:100%;padding:8px;border:1px solid var(--bg-accent);border-radius:3px;background:var(--bg-page);color:var(--color-text);font-size:14px;">
${studioCapabilityOptions}
</select>
<small style="color: var(--color-muted); display:block; margin-top:5px;">Used by Studio to decide which model can power chat, vision, image, audio, video, embeddings, and advanced tools.</small>
<div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Source: ${escHtmlAttr(capabilitySource)}${model.studio_capability_unknown ? ' · low confidence' : ''}</div>
<div style="margin-top:6px;font-size:12px;color:var(--color-text);">Current: ${inferredCaps.length ? inferredCaps.map(cap => `<span style=&quot;display:inline-block;margin:2px 4px 0 0;padding:2px 6px;border-radius:999px;background:var(--bg-accent);&quot;>${escHtmlAttr(cap)}</span>`).join('') : '<span style="color:var(--color-muted);">none</span>'}</div>
${capabilityNotes}
</div>
<div class="form-group">
<label>Studio adapter override</label>
<select onchange="updateModelStudioAdapter('${providerKey}', ${index}, this.value)">
${adapterOptions}
</select>
<small style="color: var(--color-muted); display:block; margin-top:5px;">Optional manual override for how AISBF adapts Studio workflow payloads for this model. Leave on auto to infer the best adapter from provider type and capabilities.</small>
<div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Effective adapter: ${escHtmlAttr(effectiveAdapter)}</div>
</div>
<div class="form-group">
<label>Studio adapter profile override</label>
<select onchange="updateModelStudioAdapterProfile('${providerKey}', ${index}, this.value)">
${adapterProfileOptions}
</select>
<small style="color: var(--color-muted); display:block; margin-top:5px;">Optional provider-specific profile layered above adapter family inference. Use this when a specific configured provider behaves differently from the default family mapping.</small>
<div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Effective profile: ${escHtmlAttr(effectiveAdapterProfile)}</div>
</div>
`;
container.appendChild(modelDiv);
......@@ -2356,6 +2404,23 @@ function updateModel(providerKey, index, field, value) {
providersData[providerKey].models[index][field] = value;
}
function updateModelStudioCapabilities(providerKey, index, selectEl) {
const values = Array.from(selectEl.selectedOptions).map(option => option.value).filter(Boolean);
providersData[providerKey].models[index].studio_capabilities = values;
providersData[providerKey].models[index].studio_capability_source = values.length ? 'manual' : 'heuristic';
providersData[providerKey].models[index].studio_capability_unknown = values.length === 0;
}
function updateModelStudioAdapter(providerKey, index, value) {
providersData[providerKey].models[index].studio_adapter_override = value === 'auto' ? null : value;
providersData[providerKey].models[index].studio_adapter = value || 'auto';
}
function updateModelStudioAdapterProfile(providerKey, index, value) {
providersData[providerKey].models[index].studio_adapter_profile_override = value === 'auto' ? null : value;
providersData[providerKey].models[index].studio_adapter_profile = value || 'auto';
}
function updateModelCondenseMethod(providerKey, index, value) {
const trimmed = value.trim();
if (!trimmed) {
......
......@@ -348,6 +348,14 @@ function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey];
const safeRKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const inheritedCaps = Array.isArray(rotation.capabilities) ? rotation.capabilities : [];
const partialCaps = Array.isArray(rotation.partial_capabilities) ? rotation.partial_capabilities : [];
const inheritedCapsHtml = inheritedCaps.length
? inheritedCaps.map(cap => `<span style="display:inline-block;margin:2px 6px 0 0;padding:3px 8px;border-radius:999px;background:var(--bg-accent);font-size:12px;">${escHtmlAttr(cap)}</span>`).join('')
: '<span style="color: var(--color-muted);">No capability is currently shared by every model in this rotation.</span>';
const partialCapsHtml = partialCaps.length
? `<div style="margin-top:8px;font-size:12px;color:var(--color-muted);">Partial across only some models: ${partialCaps.map(cap => escHtmlAttr(cap)).join(', ')}</div>`
: '';
container.innerHTML = `
<div class="form-group">
......@@ -365,6 +373,9 @@ function renderRotationDetails(rotationKey) {
<div class="form-group">
<label>${window.i18n.t('rotations.capabilities')}</label>
<input type="text" value="${rotation.capabilities ? rotation.capabilities.join(', ') : ''}" onchange="updateRotationCapabilities('${rotationKey}', this.value)" placeholder="${window.i18n.t('rotations.capabilities_placeholder')}">
<small style="color: var(--color-muted); display:block; margin-top:5px;">This field is auto-inherited from the capabilities shared by all models in the rotation when you save.</small>
<div style="margin-top:8px;">${inheritedCapsHtml}</div>
${partialCapsHtml}
</div>
<div class="form-group">
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -358,6 +358,14 @@ function renderAutoselectDetails(autoselectKey) {
const container = document.getElementById(`autoselect-details-${autoselectKey}`);
const autoselect = autoselectConfig[autoselectKey];
const safeAKey = autoselectKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const inheritedCaps = Array.isArray(autoselect.capabilities) ? autoselect.capabilities : [];
const partialCaps = Array.isArray(autoselect.partial_capabilities) ? autoselect.partial_capabilities : [];
const inheritedCapsHtml = inheritedCaps.length
? inheritedCaps.map(cap => `<span style="display:inline-block;margin:2px 6px 0 0;padding:3px 8px;border-radius:999px;background:var(--bg-accent);font-size:12px;">${escHtmlAttr(cap)}</span>`).join('')
: '<span style="color: var(--color-muted);">No capability is currently shared by every available model in this autoselection.</span>';
const partialCapsHtml = partialCaps.length
? `<div style="margin-top:8px;font-size:12px;color:var(--color-muted);">Partial across only some models: ${partialCaps.map(cap => escHtmlAttr(cap)).join(', ')}</div>`
: '';
// Default to "internal" if selection_model is not set
const selectionValue = autoselect.selection_model || 'internal';
......@@ -396,6 +404,9 @@ function renderAutoselectDetails(autoselectKey) {
<div class="form-group">
<label>${window.i18n.t('autoselect.capabilities')}</label>
<input type="text" value="${escHtml(autoselect.capabilities ? autoselect.capabilities.join(', ') : '')}" onchange="updateAutoselectCapabilities('${escHtml(autoselectKey)}', this.value)" placeholder="${window.i18n.t('autoselect.capabilities_placeholder')}">
<small style="color: var(--color-muted); display:block; margin-top:5px;">This field is auto-inherited from the capabilities shared by all available models when you save.</small>
<div style="margin-top:8px;">${inheritedCapsHtml}</div>
${partialCapsHtml}
</div>
<div class="form-group">
......
......@@ -277,6 +277,9 @@ async function apiCall(method, url, body) {
}
let providersData = {};
const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices_json | safe }};
const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_choices_json | safe }};
const STUDIO_ADAPTER_PROFILE_CHOICES = {{ studio_adapter_profile_choices_json | safe }};
let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
......@@ -1367,6 +1370,22 @@ function renderModels(providerKey) {
container.innerHTML = '';
provider.models.forEach((model, index) => {
const selectedStudioCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
const inferredCaps = Array.isArray(model.studio_capabilities) ? model.studio_capabilities : [];
const studioCapabilityOptions = STUDIO_CAPABILITY_CHOICES.map(cap => {
const selected = selectedStudioCaps.includes(cap) ? 'selected' : '';
return `<option value="${escHtmlAttr(cap)}" ${selected}>${escHtmlAttr(cap)}</option>`;
}).join('');
const capabilitySource = model.studio_capability_source || 'heuristic';
const selectedAdapter = model.studio_adapter_override || 'auto';
const effectiveAdapter = model.studio_adapter || 'auto';
const adapterOptions = STUDIO_ADAPTER_CHOICES.map(adapter => `<option value="${escHtmlAttr(adapter)}" ${selectedAdapter === adapter ? 'selected' : ''}>${escHtmlAttr(adapter)}</option>`).join('');
const selectedAdapterProfile = model.studio_adapter_profile_override || 'auto';
const effectiveAdapterProfile = model.studio_adapter_profile || 'auto';
const adapterProfileOptions = STUDIO_ADAPTER_PROFILE_CHOICES.map(profile => `<option value="${escHtmlAttr(profile)}" ${selectedAdapterProfile === profile ? 'selected' : ''}>${escHtmlAttr(profile)}</option>`).join('');
const capabilityNotes = Array.isArray(model.studio_capability_notes) && model.studio_capability_notes.length
? `<div style="margin-top:6px;color:var(--color-muted);font-size:12px;">${model.studio_capability_notes.map(note => escHtmlAttr(note)).join(' | ')}</div>`
: '';
const modelDiv = document.createElement('div');
modelDiv.style.cssText = 'border: 1px solid var(--color-border); padding: 15px; margin-bottom: 10px; border-radius: 3px; background: var(--bg-page);';
......@@ -1445,6 +1464,35 @@ function renderModels(providerKey) {
Overrides both provider-level cache setting and global cache setting.
</small>
</div>
<div class="form-group">
<label>Studio capabilities</label>
<select multiple size="8" onchange="updateModelStudioCapabilities('${providerKey}', ${index}, this)" style="width:100%;padding:8px;border:1px solid var(--bg-accent);border-radius:3px;background:var(--bg-page);color:var(--color-text);font-size:14px;">
${studioCapabilityOptions}
</select>
<small style="color: var(--color-muted); display:block; margin-top:5px;">Used by Studio to decide which model can power chat, vision, image, audio, video, embeddings, and advanced tools.</small>
<div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Source: ${escHtmlAttr(capabilitySource)}${model.studio_capability_unknown ? ' · low confidence' : ''}</div>
<div style="margin-top:6px;font-size:12px;color:var(--color-text);">Current: ${inferredCaps.length ? inferredCaps.map(cap => `<span style=&quot;display:inline-block;margin:2px 4px 0 0;padding:2px 6px;border-radius:999px;background:var(--bg-accent);&quot;>${escHtmlAttr(cap)}</span>`).join('') : '<span style="color:var(--color-muted);">none</span>'}</div>
${capabilityNotes}
</div>
<div class="form-group">
<label>Studio adapter override</label>
<select onchange="updateModelStudioAdapter('${providerKey}', ${index}, this.value)">
${adapterOptions}
</select>
<small style="color: var(--color-muted); display:block; margin-top:5px;">Optional manual override for how AISBF adapts Studio workflow payloads for this model. Leave on auto to infer the best adapter from provider type and capabilities.</small>
<div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Effective adapter: ${escHtmlAttr(effectiveAdapter)}</div>
</div>
<div class="form-group">
<label>Studio adapter profile override</label>
<select onchange="updateModelStudioAdapterProfile('${providerKey}', ${index}, this.value)">
${adapterProfileOptions}
</select>
<small style="color: var(--color-muted); display:block; margin-top:5px;">Optional provider-specific profile layered above adapter family inference. Use this when a specific configured provider behaves differently from the default family mapping.</small>
<div style="margin-top:6px;font-size:12px;color:var(--color-muted);">Effective profile: ${escHtmlAttr(effectiveAdapterProfile)}</div>
</div>
`;
container.appendChild(modelDiv);
......@@ -2365,6 +2413,23 @@ function updateModel(providerKey, index, field, value) {
providersData[providerKey].models[index][field] = value;
}
function updateModelStudioCapabilities(providerKey, index, selectEl) {
const values = Array.from(selectEl.selectedOptions).map(option => option.value).filter(Boolean);
providersData[providerKey].models[index].studio_capabilities = values;
providersData[providerKey].models[index].studio_capability_source = values.length ? 'manual' : 'heuristic';
providersData[providerKey].models[index].studio_capability_unknown = values.length === 0;
}
function updateModelStudioAdapter(providerKey, index, value) {
providersData[providerKey].models[index].studio_adapter_override = value === 'auto' ? null : value;
providersData[providerKey].models[index].studio_adapter = value || 'auto';
}
function updateModelStudioAdapterProfile(providerKey, index, value) {
providersData[providerKey].models[index].studio_adapter_profile_override = value === 'auto' ? null : value;
providersData[providerKey].models[index].studio_adapter_profile = value || 'auto';
}
function updateModelCondenseMethod(providerKey, index, value) {
const trimmed = value.trim();
if (!trimmed) {
......
......@@ -338,6 +338,14 @@ function renderRotationDetails(rotationKey) {
const container = document.getElementById(`rotation-details-${rotationKey}`);
const rotation = rotationsConfig.rotations[rotationKey];
const safeRKey = rotationKey.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const inheritedCaps = Array.isArray(rotation.capabilities) ? rotation.capabilities : [];
const partialCaps = Array.isArray(rotation.partial_capabilities) ? rotation.partial_capabilities : [];
const inheritedCapsHtml = inheritedCaps.length
? inheritedCaps.map(cap => `<span style="display:inline-block;margin:2px 6px 0 0;padding:3px 8px;border-radius:999px;background:var(--bg-accent);font-size:12px;">${escHtmlAttr(cap)}</span>`).join('')
: '<span style="color: var(--color-muted);">No capability is currently shared by every model in this rotation.</span>';
const partialCapsHtml = partialCaps.length
? `<div style="margin-top:8px;font-size:12px;color:var(--color-muted);">Partial across only some models: ${partialCaps.map(cap => escHtmlAttr(cap)).join(', ')}</div>`
: '';
container.innerHTML = `
<div class="form-group">
......@@ -359,6 +367,9 @@ function renderRotationDetails(rotationKey) {
<div class="form-group">
<label>${window.i18n.t('rotations.capabilities')}</label>
<input type="text" value="${rotation.capabilities ? rotation.capabilities.join(', ') : ''}" onchange="updateRotationCapabilities('${rotationKey}', this.value)" placeholder="${window.i18n.t('rotations.capabilities_placeholder')}">
<small style="color: var(--color-muted); display:block; margin-top:5px;">This field is auto-inherited from the capabilities shared by all models in the rotation when you save.</small>
<div style="margin-top:8px;">${inheritedCapsHtml}</div>
${partialCapsHtml}
</div>
<div class="form-group">
......
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