Phase 2: Admin panel, analytics, storage, template pipeline, multi-provider LLM

- Fix admin sidebar: remove duplicate Teams, add Storage nav item
- Analytics: client-scoped queries, super_admin sees all (including NULL client_id)
- Storage management: list/download/delete presentations with file metadata
- Settings page with brand config router
- AI usage tracking: new AIUsageModel, ai_usage_service, analytics endpoint
- Master deck → template bridge: _register_as_template creates TemplateModel
  + PresentationLayoutCodeModel so parsed layouts appear in template picker
- Multi-provider LLM vision in parser: Anthropic/Google/OpenAI with asyncio.to_thread
- Fix PPTX upload 400: accept by .pptx extension (browser sends octet-stream)
- Fix reparse FK violation: presentation_id=None for parse_master_deck jobs
- Worker job_timeout increased to 1800s for LLM-heavy master deck parsing
- PYTHONUNBUFFERED=1 in docker-compose worker for real-time log output
- Auth: clientId in /me response, dev-login cookie improvements
- Frontend: auth slice clientId, master-deck thumbnails, storage page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-26 23:39:34 +00:00
parent 2ecacacb3f
commit d3d1667a79
30 changed files with 1845 additions and 186 deletions

View file

@ -19,7 +19,7 @@ DEV_AUTH_PASSWORD=devpass123
# LLM Provider — Claude Sonnet 4.6 for all text generation
LLM=anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
ANTHROPIC_MODEL=claude-sonnet-4-6
# Image Provider — NanoBanana Pro for image generation
GOOGLE_API_KEY=

View file

@ -28,5 +28,11 @@ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/pytho
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
# Pre-download ONNX embedding model so it's baked into the image
RUN mkdir -p chroma/models && \
curl -fsSL -o chroma/models/onnx.tar.gz \
https://chroma-onnx-models.s3.amazonaws.com/all-MiniLM-L6-v2/onnx.tar.gz && \
tar -xzf chroma/models/onnx.tar.gz -C chroma/models/
EXPOSE 8000
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -14,6 +14,8 @@ from api.v1.admin.audit_router import AUDIT_ROUTER
from api.v1.admin.brand_config_router import BRAND_CONFIG_ROUTER
from api.v1.admin.master_decks_router import MASTER_DECKS_ROUTER
from api.v1.admin.analytics_router import ANALYTICS_ROUTER
from api.v1.admin.storage_router import STORAGE_ROUTER
from api.v1.admin.settings_router import SETTINGS_ROUTER
from api.v1.ppt.endpoints.jobs import JOBS_ROUTER
from api.v1.ppt.endpoints.review import REVIEW_ROUTER
from api.v1.ppt.endpoints.export import EXPORT_ROUTER
@ -31,6 +33,8 @@ ADMIN_ROUTER.include_router(AUDIT_ROUTER)
ADMIN_ROUTER.include_router(BRAND_CONFIG_ROUTER)
ADMIN_ROUTER.include_router(MASTER_DECKS_ROUTER)
ADMIN_ROUTER.include_router(ANALYTICS_ROUTER)
ADMIN_ROUTER.include_router(STORAGE_ROUTER)
ADMIN_ROUTER.include_router(SETTINGS_ROUTER)
# Routers
app.include_router(AUTH_ROUTER)

View file

@ -8,9 +8,12 @@ from services.auth_service import AuthService
from services.database import async_session_maker
from models.sql.user import UserModel
# Paths that skip authentication
PUBLIC_PATH_PREFIXES = [
"/api/v1/auth/",
# Paths that skip authentication (exact or prefix match)
PUBLIC_PATHS = [
"/api/v1/auth/dev-status",
"/api/v1/auth/dev-login",
"/api/v1/auth/login",
"/api/v1/auth/callback",
"/docs",
"/openapi.json",
"/api/health",
@ -24,8 +27,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
path = request.url.path
# Skip auth for public paths
for prefix in PUBLIC_PATH_PREFIXES:
if path.startswith(prefix):
for public_path in PUBLIC_PATHS:
if path == public_path or path.startswith(public_path + "/"):
request.state.user = None
return await call_next(request)

View file

@ -4,19 +4,46 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select, and_, case
from sqlalchemy import func, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from models.sql.presentation import PresentationModel
from models.sql.user import UserModel
from models.sql.audit_log import AuditLogModel
from models.sql.job import JobModel
from services.access_service import get_accessible_client_ids
from services.database import get_async_session
from utils.auth_dependencies import require_client_admin
ANALYTICS_ROUTER = APIRouter(tags=["Analytics"])
async def _resolve_client_filter(
client_id: Optional[uuid.UUID],
user: UserModel,
session: AsyncSession,
):
"""Return SQLAlchemy filter for presentation client scoping.
super_admin with no filter no restriction (sees all including NULL client_id).
client_admin scoped to their accessible clients.
Explicit client_id filter to that client only.
"""
if client_id:
return PresentationModel.client_id == client_id
if user.role == "super_admin":
# Super admin sees everything — no client filter needed
return None
cids = await get_accessible_client_ids(user, session)
if not cids:
# No accessible clients — return impossible condition
return PresentationModel.client_id == None # noqa: E711
if len(cids) == 1:
return PresentationModel.client_id == cids[0]
return PresentationModel.client_id.in_(cids)
@ANALYTICS_ROUTER.get("/analytics/overview")
async def analytics_overview(
client_id: Optional[uuid.UUID] = Query(None),
@ -28,10 +55,13 @@ async def analytics_overview(
month_ago = now - timedelta(days=30)
week_ago = now - timedelta(days=7)
# Base query filter
base = select(PresentationModel).where(PresentationModel.deleted_at.is_(None))
if client_id:
base = base.where(PresentationModel.client_id == client_id)
cf = await _resolve_client_filter(client_id, current_user, session)
base_filters = [PresentationModel.deleted_at.is_(None)]
if cf is not None:
base_filters.append(cf)
base = select(PresentationModel).where(and_(*base_filters))
# Total presentations
total_q = select(func.count()).select_from(base.subquery())
@ -49,34 +79,34 @@ async def analytics_overview(
)
this_week = (await session.execute(week_q)).scalar() or 0
# Active users (users who created presentations in last 30 days)
# Active users (last 30 days)
active_filters = [
PresentationModel.deleted_at.is_(None),
PresentationModel.created_at >= month_ago,
PresentationModel.owner_id.isnot(None),
]
if cf is not None:
active_filters.append(cf)
active_q = select(func.count(func.distinct(PresentationModel.owner_id))).where(
and_(
PresentationModel.deleted_at.is_(None),
PresentationModel.created_at >= month_ago,
PresentationModel.owner_id.isnot(None),
)
and_(*active_filters)
)
if client_id:
active_q = active_q.where(PresentationModel.client_id == client_id)
active_users = (await session.execute(active_q)).scalar() or 0
# Approval rate
reviewed_q = select(func.count()).where(
and_(
PresentationModel.deleted_at.is_(None),
PresentationModel.status.in_(["approved", "in_review"]),
)
)
approved_q = select(func.count()).where(
and_(
PresentationModel.deleted_at.is_(None),
PresentationModel.status == "approved",
)
)
if client_id:
reviewed_q = reviewed_q.where(PresentationModel.client_id == client_id)
approved_q = approved_q.where(PresentationModel.client_id == client_id)
reviewed_filters = [
PresentationModel.deleted_at.is_(None),
PresentationModel.status.in_(["approved", "in_review"]),
]
if cf is not None:
reviewed_filters.append(cf)
reviewed_q = select(func.count()).where(and_(*reviewed_filters))
approved_filters = [
PresentationModel.deleted_at.is_(None),
PresentationModel.status == "approved",
]
if cf is not None:
approved_filters.append(cf)
approved_q = select(func.count()).where(and_(*approved_filters))
reviewed = (await session.execute(reviewed_q)).scalar() or 0
approved = (await session.execute(approved_q)).scalar() or 0
approval_rate = round((approved / reviewed * 100) if reviewed > 0 else 0, 1)
@ -101,45 +131,45 @@ async def analytics_usage(
now = datetime.now(timezone.utc)
since = now - timedelta(days=days)
cf = await _resolve_client_filter(client_id, current_user, session)
# Decks per day
daily_filters = [
PresentationModel.deleted_at.is_(None),
PresentationModel.created_at >= since,
]
if cf is not None:
daily_filters.append(cf)
daily_q = (
select(
func.date(PresentationModel.created_at).label("day"),
func.count().label("count"),
)
.where(
and_(
PresentationModel.deleted_at.is_(None),
PresentationModel.created_at >= since,
)
)
.where(and_(*daily_filters))
.group_by(func.date(PresentationModel.created_at))
.order_by(func.date(PresentationModel.created_at))
)
if client_id:
daily_q = daily_q.where(PresentationModel.client_id == client_id)
daily_result = await session.execute(daily_q)
daily = [{"date": str(row.day), "count": row.count} for row in daily_result]
# Top users
top_filters = [
PresentationModel.deleted_at.is_(None),
PresentationModel.owner_id.isnot(None),
PresentationModel.created_at >= since,
]
if cf is not None:
top_filters.append(cf)
top_users_q = (
select(
PresentationModel.owner_id,
func.count().label("count"),
)
.where(
and_(
PresentationModel.deleted_at.is_(None),
PresentationModel.owner_id.isnot(None),
PresentationModel.created_at >= since,
)
)
.where(and_(*top_filters))
.group_by(PresentationModel.owner_id)
.order_by(func.count().desc())
.limit(10)
)
if client_id:
top_users_q = top_users_q.where(PresentationModel.client_id == client_id)
top_result = await session.execute(top_users_q)
top_users = [
{"user_id": str(row.owner_id), "count": row.count} for row in top_result
@ -158,9 +188,11 @@ async def analytics_quality(
session: AsyncSession = Depends(get_async_session),
):
"""Quality metrics: status distribution, average comments."""
cf = await _resolve_client_filter(client_id, current_user, session)
base_filter = [PresentationModel.deleted_at.is_(None)]
if client_id:
base_filter.append(PresentationModel.client_id == client_id)
if cf is not None:
base_filter.append(cf)
# Status distribution
status_q = (
@ -196,11 +228,17 @@ async def analytics_performance(
session: AsyncSession = Depends(get_async_session),
):
"""Performance metrics: job durations and error rates."""
base_filter = []
# Build job filter
job_filter = []
if client_id:
# Jobs don't have client_id directly, so we join through presentations
# For simplicity, return unfiltered job stats
pass
job_filter.append(JobModel.client_id == client_id)
elif current_user.role != "super_admin":
cids = await get_accessible_client_ids(current_user, session)
if cids:
if len(cids) == 1:
job_filter.append(JobModel.client_id == cids[0])
else:
job_filter.append(JobModel.client_id.in_(cids))
# Job status distribution
job_status_q = (
@ -208,24 +246,29 @@ async def analytics_performance(
JobModel.status,
func.count().label("count"),
)
.group_by(JobModel.status)
)
.where(and_(*job_filter)) if job_filter else
select(
JobModel.status,
func.count().label("count"),
)
).group_by(JobModel.status)
job_result = await session.execute(job_status_q)
job_status = {row.status: row.count for row in job_result}
# Average generation time (completed jobs only)
avg_base = [
JobModel.status == "completed",
JobModel.started_at.isnot(None),
JobModel.completed_at.isnot(None),
]
if job_filter:
avg_base.extend(job_filter)
avg_time_q = select(
func.avg(
func.extract("epoch", JobModel.completed_at)
- func.extract("epoch", JobModel.started_at)
)
).where(
and_(
JobModel.status == "completed",
JobModel.started_at.isnot(None),
JobModel.completed_at.isnot(None),
)
)
).where(and_(*avg_base))
avg_seconds = (await session.execute(avg_time_q)).scalar()
avg_generation_time = round(avg_seconds, 1) if avg_seconds else None
@ -240,3 +283,97 @@ async def analytics_performance(
"error_rate": error_rate,
"total_jobs": total_jobs,
}
@ANALYTICS_ROUTER.get("/analytics/ai-usage")
async def analytics_ai_usage(
client_id: Optional[uuid.UUID] = Query(None),
days: int = Query(30, ge=7, le=90),
current_user: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""AI model usage: total calls, tokens consumed, breakdown by provider."""
from models.sql.ai_usage import AIUsageModel
since = datetime.now(timezone.utc) - timedelta(days=days)
base_filter = [AIUsageModel.created_at >= since]
if client_id:
base_filter.append(AIUsageModel.client_id == client_id)
elif current_user.role != "super_admin":
cids = await get_accessible_client_ids(current_user, session)
if cids:
if len(cids) == 1:
base_filter.append(AIUsageModel.client_id == cids[0])
else:
base_filter.append(AIUsageModel.client_id.in_(cids))
# Totals
totals_q = select(
func.count().label("total_calls"),
func.coalesce(func.sum(AIUsageModel.input_tokens), 0).label("total_input_tokens"),
func.coalesce(func.sum(AIUsageModel.output_tokens), 0).label("total_output_tokens"),
func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("total_tokens"),
).where(and_(*base_filter))
totals = (await session.execute(totals_q)).one()
# By provider
by_provider_q = (
select(
AIUsageModel.provider,
func.count().label("calls"),
func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"),
)
.where(and_(*base_filter))
.group_by(AIUsageModel.provider)
)
provider_result = await session.execute(by_provider_q)
by_provider = [
{"provider": row.provider, "calls": row.calls, "tokens": row.tokens}
for row in provider_result
]
# By model
by_model_q = (
select(
AIUsageModel.model,
func.count().label("calls"),
func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"),
)
.where(and_(*base_filter))
.group_by(AIUsageModel.model)
.order_by(func.count().desc())
.limit(10)
)
model_result = await session.execute(by_model_q)
by_model = [
{"model": row.model, "calls": row.calls, "tokens": row.tokens}
for row in model_result
]
# Daily usage
daily_q = (
select(
func.date(AIUsageModel.created_at).label("day"),
func.count().label("calls"),
func.coalesce(func.sum(AIUsageModel.total_tokens), 0).label("tokens"),
)
.where(and_(*base_filter))
.group_by(func.date(AIUsageModel.created_at))
.order_by(func.date(AIUsageModel.created_at))
)
daily_result = await session.execute(daily_q)
daily = [
{"date": str(row.day), "calls": row.calls, "tokens": row.tokens}
for row in daily_result
]
return {
"total_calls": totals.total_calls,
"total_input_tokens": totals.total_input_tokens,
"total_output_tokens": totals.total_output_tokens,
"total_tokens": totals.total_tokens,
"by_provider": by_provider,
"by_model": by_model,
"daily": daily,
}

View file

@ -35,7 +35,17 @@ async def get_brand_config(
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="Brand config not found")
return {
"id": None,
"client_id": str(client_id),
"primary_colors": None,
"secondary_colors": None,
"fonts": None,
"logo_paths": None,
"voice_rules": None,
"voice_examples": None,
"guideline_doc_path": None,
}
return {
"id": str(config.id),

View file

@ -5,6 +5,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -18,7 +19,7 @@ from utils.auth_dependencies import require_client_admin
MASTER_DECKS_ROUTER = APIRouter(tags=["Admin - Master Decks"])
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "data")
DATA_DIR = os.environ.get("APP_DATA_DIRECTORY", os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "data"))
def _deck_dir(client_id: uuid.UUID, deck_id: uuid.UUID) -> str:
@ -40,18 +41,10 @@ class LayoutUpdate(BaseModel):
react_code: Optional[str] = None
# --- Endpoints ---
# --- Helpers ---
@MASTER_DECKS_ROUTER.get("/clients/{client_id}/master-decks")
async def list_master_decks(
client_id: uuid.UUID,
include_inactive: bool = Query(False),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
async def _list_decks(client_id: uuid.UUID, include_inactive: bool, session: AsyncSession):
stmt = select(MasterDeckModel).where(MasterDeckModel.client_id == client_id)
if not include_inactive:
stmt = stmt.where(MasterDeckModel.is_active == True)
@ -77,6 +70,31 @@ async def list_master_decks(
]
# --- Endpoints ---
@MASTER_DECKS_ROUTER.get("/master-decks")
async def list_master_decks_flat(
client_id: uuid.UUID = Query(...),
include_inactive: bool = Query(False),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
return await _list_decks(client_id, include_inactive, session)
@MASTER_DECKS_ROUTER.get("/clients/{client_id}/master-decks")
async def list_master_decks(
client_id: uuid.UUID,
include_inactive: bool = Query(False),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
await check_team_admin(admin, client_id, session)
return await _list_decks(client_id, include_inactive, session)
@MASTER_DECKS_ROUTER.post("/clients/{client_id}/master-decks")
async def upload_master_deck(
client_id: uuid.UUID,
@ -86,7 +104,9 @@ async def upload_master_deck(
):
await check_team_admin(admin, client_id, session)
if file.content_type not in POWERPOINT_TYPES:
is_pptx_mime = file.content_type in POWERPOINT_TYPES
is_pptx_ext = (file.filename or "").lower().endswith(".pptx")
if not is_pptx_mime and not is_pptx_ext:
raise HTTPException(status_code=400, detail="Only PPTX files are accepted")
if hasattr(file, "size") and file.size and file.size > 100 * 1024 * 1024:
@ -122,7 +142,7 @@ async def upload_master_deck(
job = JobModel(
user_id=admin.id,
client_id=client_id,
presentation_id=deck_id, # reuse field for deck_id
presentation_id=None,
job_type="parse_master_deck",
status="queued",
progress=0,
@ -130,7 +150,7 @@ async def upload_master_deck(
)
session.add(job)
await session.commit()
await enqueue_job("parse_master_deck_task", job_id=str(job.id))
await enqueue_job("parse_master_deck_task", job_id=str(job.id), deck_id=str(deck_id))
except Exception:
import asyncio
from services.master_deck_parser_service import parse_master_deck
@ -258,7 +278,7 @@ async def reparse_master_deck(
job = JobModel(
user_id=admin.id,
client_id=deck.client_id,
presentation_id=deck_id,
presentation_id=None,
job_type="parse_master_deck",
status="queued",
progress=0,
@ -266,8 +286,9 @@ async def reparse_master_deck(
)
session.add(job)
await session.commit()
await enqueue_job("parse_master_deck_task", job_id=str(job.id))
except Exception:
await enqueue_job("parse_master_deck_task", job_id=str(job.id), deck_id=str(deck_id))
except Exception as e:
print(f"[reparse] Failed to enqueue job, falling back to async: {e}")
import asyncio
from services.master_deck_parser_service import parse_master_deck
asyncio.create_task(parse_master_deck(deck_id))
@ -292,3 +313,31 @@ async def delete_master_deck(
await session.commit()
return {"ok": True}
@MASTER_DECKS_ROUTER.get("/master-decks/{deck_id}/screenshot/{filename}")
async def get_master_deck_screenshot(
deck_id: uuid.UUID,
filename: str,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Serve a master deck screenshot image with auth check."""
deck = await session.get(MasterDeckModel, deck_id)
if not deck:
raise HTTPException(status_code=404, detail="Master deck not found")
await check_team_admin(admin, deck.client_id, session)
# Sanitize filename to prevent path traversal
safe_name = os.path.basename(filename)
screenshot_dir = os.path.join(
DATA_DIR, "clients", str(deck.client_id),
"master_decks", str(deck_id), "screenshots"
)
file_path = os.path.join(screenshot_dir, safe_name)
if not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Screenshot not found")
return FileResponse(file_path, media_type="image/png")

View file

@ -0,0 +1,128 @@
"""Admin router for system settings — LLM and image provider configuration."""
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from models.sql.user import UserModel
from services.database import get_async_session
from utils.auth_dependencies import require_client_admin
SETTINGS_ROUTER = APIRouter(tags=["Admin - Settings"])
class SystemSettings(BaseModel):
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
image_provider: Optional[str] = None
anthropic_api_key_set: bool = False
openai_api_key_set: bool = False
google_api_key_set: bool = False
class SettingsUpdate(BaseModel):
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
image_provider: Optional[str] = None
anthropic_api_key: Optional[str] = None
openai_api_key: Optional[str] = None
google_api_key: Optional[str] = None
LLM_PROVIDERS = ["anthropic", "openai", "google", "ollama", "custom"]
IMAGE_PROVIDERS = ["google", "dall-e-3", "gpt-image-1.5", "pexels", "pixabay", "comfyui"]
@SETTINGS_ROUTER.get("/settings")
async def get_settings(
admin: UserModel = Depends(require_client_admin),
_session: AsyncSession = Depends(get_async_session),
):
"""Return current system settings (env-based config)."""
if admin.role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin only")
return {
"llm_provider": os.getenv("LLM", "anthropic"),
"llm_model": _get_current_model(),
"image_provider": os.getenv("IMAGE_PROVIDER", "google"),
"anthropic_api_key_set": bool(os.getenv("ANTHROPIC_API_KEY")),
"openai_api_key_set": bool(os.getenv("OPENAI_API_KEY")),
"google_api_key_set": bool(os.getenv("GOOGLE_API_KEY")),
"available_llm_providers": LLM_PROVIDERS,
"available_image_providers": IMAGE_PROVIDERS,
}
@SETTINGS_ROUTER.put("/settings")
async def update_settings(
body: SettingsUpdate,
admin: UserModel = Depends(require_client_admin),
_session: AsyncSession = Depends(get_async_session),
):
"""Update system settings (runtime env vars). Changes don't persist across restarts."""
if admin.role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin only")
changed = []
if body.llm_provider is not None:
if body.llm_provider not in LLM_PROVIDERS:
raise HTTPException(status_code=400, detail=f"Invalid LLM provider: {body.llm_provider}")
os.environ["LLM"] = body.llm_provider
changed.append("llm_provider")
if body.llm_model is not None:
provider = body.llm_provider or os.getenv("LLM", "anthropic")
model_env_map = {
"anthropic": "ANTHROPIC_MODEL",
"openai": "OPENAI_MODEL",
"google": "GOOGLE_MODEL",
"ollama": "OLLAMA_MODEL",
"custom": "CUSTOM_MODEL",
}
env_key = model_env_map.get(provider)
if env_key:
os.environ[env_key] = body.llm_model
changed.append("llm_model")
if body.image_provider is not None:
if body.image_provider not in IMAGE_PROVIDERS:
raise HTTPException(status_code=400, detail=f"Invalid image provider: {body.image_provider}")
os.environ["IMAGE_PROVIDER"] = body.image_provider
changed.append("image_provider")
if body.anthropic_api_key is not None:
os.environ["ANTHROPIC_API_KEY"] = body.anthropic_api_key
changed.append("anthropic_api_key")
if body.openai_api_key is not None:
os.environ["OPENAI_API_KEY"] = body.openai_api_key
changed.append("openai_api_key")
if body.google_api_key is not None:
os.environ["GOOGLE_API_KEY"] = body.google_api_key
changed.append("google_api_key")
return {"ok": True, "changed": changed}
def _get_current_model() -> str:
provider = os.getenv("LLM", "anthropic")
model_env_map = {
"anthropic": "ANTHROPIC_MODEL",
"openai": "OPENAI_MODEL",
"google": "GOOGLE_MODEL",
"ollama": "OLLAMA_MODEL",
"custom": "CUSTOM_MODEL",
}
from constants.llm import DEFAULT_ANTHROPIC_MODEL, DEFAULT_OPENAI_MODEL, DEFAULT_GOOGLE_MODEL
defaults = {
"anthropic": DEFAULT_ANTHROPIC_MODEL,
"openai": DEFAULT_OPENAI_MODEL,
"google": DEFAULT_GOOGLE_MODEL,
}
env_key = model_env_map.get(provider, "ANTHROPIC_MODEL")
return os.getenv(env_key, defaults.get(provider, ""))

View file

@ -0,0 +1,191 @@
"""Admin router for storage management — list, download, delete presentations."""
import os
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import func, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from models.sql.presentation import PresentationModel
from models.sql.user import UserModel
from services import audit_service
from services.access_service import get_accessible_client_ids
from services.database import get_async_session
from utils.auth_dependencies import require_client_admin
from utils.datetime_utils import get_current_utc_datetime
STORAGE_ROUTER = APIRouter(tags=["Admin - Storage"])
async def _resolve_client_filter(client_id, user, session):
"""Return SQLAlchemy filter or None (super_admin sees all)."""
if client_id:
return PresentationModel.client_id == client_id
if user.role == "super_admin":
return None
cids = await get_accessible_client_ids(user, session)
if not cids:
return PresentationModel.client_id == None # noqa: E711
if len(cids) == 1:
return PresentationModel.client_id == cids[0]
return PresentationModel.client_id.in_(cids)
@STORAGE_ROUTER.get("/storage/summary")
async def storage_summary(
client_id: Optional[uuid.UUID] = Query(None),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Storage summary: total presentations, files, and disk usage."""
cf = await _resolve_client_filter(client_id, admin, session)
filters = [PresentationModel.deleted_at.is_(None)]
if cf is not None:
filters.append(cf)
# Count presentations
count_q = select(func.count()).where(and_(*filters))
total_presentations = (await session.execute(count_q)).scalar() or 0
# Get all file_paths to compute size
path_filters = [
PresentationModel.deleted_at.is_(None),
PresentationModel.file_paths.isnot(None),
]
if cf is not None:
path_filters.append(cf)
paths_q = select(PresentationModel.file_paths).where(and_(*path_filters))
result = await session.execute(paths_q)
all_paths = result.scalars().all()
total_files = 0
total_size_bytes = 0
for file_paths in all_paths:
if not file_paths:
continue
for path in file_paths:
if path and os.path.isfile(path):
total_files += 1
total_size_bytes += os.path.getsize(path)
return {
"total_presentations": total_presentations,
"total_files": total_files,
"total_size_bytes": total_size_bytes,
}
@STORAGE_ROUTER.get("/storage/presentations")
async def list_storage_presentations(
client_id: Optional[uuid.UUID] = Query(None),
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""List presentations with file metadata for storage management."""
cf = await _resolve_client_filter(client_id, admin, session)
filters = [PresentationModel.deleted_at.is_(None)]
if cf is not None:
filters.append(cf)
stmt = (
select(PresentationModel)
.where(and_(*filters))
.order_by(PresentationModel.created_at.desc())
)
result = await session.execute(stmt)
presentations = result.scalars().all()
items = []
for p in presentations:
file_count = 0
total_size = 0
if p.file_paths:
for path in p.file_paths:
if path and os.path.isfile(path):
file_count += 1
total_size += os.path.getsize(path)
items.append({
"id": str(p.id),
"title": p.title,
"status": p.status,
"created_at": p.created_at.isoformat() if p.created_at else None,
"file_count": file_count,
"total_size_bytes": total_size,
"has_export": bool(p.file_paths and any(
fp.endswith(".pptx") for fp in p.file_paths if fp
)),
})
return items
@STORAGE_ROUTER.get("/storage/presentations/{presentation_id}/download")
async def download_presentation(
presentation_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Download the PPTX export file for a presentation."""
presentation = await session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
# Verify access
if presentation.client_id:
cids = await get_accessible_client_ids(admin, session)
if presentation.client_id not in cids:
raise HTTPException(status_code=403, detail="Access denied")
if not presentation.file_paths:
raise HTTPException(status_code=404, detail="No export files available")
# Find the PPTX file
pptx_path = next(
(p for p in presentation.file_paths if p and p.endswith(".pptx") and os.path.isfile(p)),
None,
)
if not pptx_path:
raise HTTPException(status_code=404, detail="PPTX file not found on disk")
filename = f"{presentation.title or 'presentation'}.pptx"
return FileResponse(
pptx_path,
filename=filename,
media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
)
@STORAGE_ROUTER.delete("/storage/presentations/{presentation_id}")
async def delete_presentation_storage(
presentation_id: uuid.UUID,
admin: UserModel = Depends(require_client_admin),
session: AsyncSession = Depends(get_async_session),
):
"""Soft-delete a presentation (files cleaned up by retention service)."""
presentation = await session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
# Verify access
if presentation.client_id:
cids = await get_accessible_client_ids(admin, session)
if presentation.client_id not in cids:
raise HTTPException(status_code=403, detail="Access denied")
presentation.deleted_at = get_current_utc_datetime()
await session.commit()
audit_service.log(
user_id=admin.id,
action="admin_delete",
resource_type="presentation",
resource_id=presentation.id,
client_id=presentation.client_id,
)
return {"ok": True}

View file

@ -61,7 +61,7 @@ async def callback(
user = await auth_service.get_or_create_user(claims, session)
token = auth_service.create_session_jwt(user)
response = RedirectResponse(url="/upload", status_code=302)
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
key="session_token",
value=token,
@ -112,17 +112,26 @@ async def dev_login(
@AUTH_ROUTER.get("/me")
async def get_current_user_info(request: Request):
async def get_current_user_info(
request: Request,
session: AsyncSession = Depends(get_async_session),
):
"""Return current authenticated user info."""
user = getattr(request.state, "user", None)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
from services.access_service import get_accessible_client_ids
client_ids = await get_accessible_client_ids(user, session)
primary_client_id = str(client_ids[0]) if client_ids else None
return {
"id": str(user.id),
"email": user.email,
"displayName": user.display_name,
"role": user.role,
"clientId": primary_client_id,
}

View file

@ -3,4 +3,4 @@ OPENAI_URL = "https://api.openai.com/v1"
# Default models
DEFAULT_OPENAI_MODEL = "gpt-4.1"
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"

View file

@ -27,6 +27,7 @@ from models.sql.brand_config import BrandConfigModel # noqa: F401
from models.sql.master_deck import MasterDeckModel # noqa: F401
from models.sql.audit_log import AuditLogModel # noqa: F401
from models.sql.job import JobModel # noqa: F401
from models.sql.ai_usage import AIUsageModel # noqa: F401
from utils.db_utils import get_database_url_and_connect_args

View file

@ -0,0 +1,48 @@
"""add_ai_usage_logs_table
Revision ID: 513b48dbb16c
Revises: 0a8788565a3e
Create Date: 2026-02-26 22:11:20.453662
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '513b48dbb16c'
down_revision: Union[str, None] = '0a8788565a3e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('ai_usage_logs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('client_id', sa.Uuid(), nullable=True),
sa.Column('presentation_id', sa.Uuid(), nullable=True),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('model', sa.String(), nullable=False),
sa.Column('call_type', sa.String(), nullable=False),
sa.Column('input_tokens', sa.Integer(), nullable=True),
sa.Column('output_tokens', sa.Integer(), nullable=True),
sa.Column('total_tokens', sa.Integer(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
sa.ForeignKeyConstraint(['presentation_id'], ['presentations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('ai_usage_logs')
# ### end Alembic commands ###

View file

@ -0,0 +1,36 @@
"""AI usage tracking model for analytics."""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class AIUsageModel(SQLModel, table=True):
__tablename__ = "ai_usage_logs"
id: uuid.UUID = Field(primary_key=True, default_factory=uuid.uuid4)
user_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("users.id"), nullable=True), default=None
)
client_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("clients.id"), nullable=True), default=None
)
presentation_id: Optional[uuid.UUID] = Field(
sa_column=Column(ForeignKey("presentations.id"), nullable=True), default=None
)
provider: str = Field(sa_column=Column(String, nullable=False))
model: str = Field(sa_column=Column(String, nullable=False))
call_type: str = Field(sa_column=Column(String, nullable=False))
input_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None)
output_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None)
total_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None)
duration_ms: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None)
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
),
)

View file

@ -1,4 +1,4 @@
"""Seed the database with the default Oliver Team."""
"""Seed the database with the default Oliver Team and admin user."""
import asyncio
import uuid
from datetime import datetime, timezone
@ -8,32 +8,70 @@ from services.database import async_session_maker
# Import all models so SQLAlchemy resolves FK references
from models.sql.client import ClientModel # noqa: F401
from models.sql.user import UserModel # noqa: F401
from models.sql.user import UserModel
from models.sql.team import TeamModel
from models.sql.team_membership import TeamMembershipModel # noqa: F401
from models.sql.team_membership import TeamMembershipModel
async def seed():
async with async_session_maker() as session:
# --- Oliver Team ---
result = await session.execute(
select(TeamModel).where(TeamModel.is_default == True) # noqa: E712
)
existing = result.scalar_one_or_none()
oliver_team = result.scalar_one_or_none()
if existing:
print(f"Oliver Team already exists (id: {existing.id}), skipping seed.")
return
if oliver_team:
print(f"Oliver Team already exists (id: {oliver_team.id})")
else:
oliver_team = TeamModel(
id=uuid.uuid4(),
name="Oliver Team",
client_id=None,
is_default=True,
created_at=datetime.now(timezone.utc),
)
session.add(oliver_team)
await session.flush()
print(f"Created Oliver Team with id: {oliver_team.id}")
oliver_team = TeamModel(
id=uuid.uuid4(),
name="Oliver Team",
client_id=None,
is_default=True,
created_at=datetime.now(timezone.utc),
# --- Admin user ---
admin_email = "admin@deckforge.dev"
result = await session.execute(
select(UserModel).where(UserModel.email == admin_email)
)
session.add(oliver_team)
admin_user = result.scalar_one_or_none()
if admin_user:
if admin_user.role != "super_admin":
admin_user.role = "super_admin"
session.add(admin_user)
print(f"Updated {admin_email} role to super_admin")
else:
print(f"Admin user already exists (id: {admin_user.id})")
else:
admin_user = UserModel(
email=admin_email,
display_name="Admin",
role="super_admin",
is_active=True,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add(admin_user)
await session.flush()
print(f"Created admin user with id: {admin_user.id}")
# Add to Oliver Team
membership = TeamMembershipModel(
user_id=admin_user.id,
team_id=oliver_team.id,
assigned_at=datetime.now(timezone.utc),
)
session.add(membership)
await session.commit()
print(f"Created Oliver Team with id: {oliver_team.id}")
print("Seed complete.")
if __name__ == "__main__":

View file

@ -0,0 +1,80 @@
"""Fire-and-forget AI usage logging service."""
import asyncio
import uuid
from typing import Optional
from services.database import async_session_maker
from utils.llm_context import get_llm_context
def log(
provider: str,
model: str,
call_type: str,
input_tokens: Optional[int] = None,
output_tokens: Optional[int] = None,
total_tokens: Optional[int] = None,
duration_ms: Optional[int] = None,
user_id: Optional[uuid.UUID] = None,
client_id: Optional[uuid.UUID] = None,
presentation_id: Optional[uuid.UUID] = None,
) -> None:
"""Log AI usage asynchronously (fire-and-forget).
If user_id/client_id/presentation_id are not provided,
reads them from the LLMCallContext contextvar.
"""
ctx = get_llm_context()
if ctx:
user_id = user_id or ctx.user_id
client_id = client_id or ctx.client_id
presentation_id = presentation_id or ctx.presentation_id
asyncio.create_task(
_write_log(
provider=provider,
model=model,
call_type=call_type,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
duration_ms=duration_ms,
user_id=user_id,
client_id=client_id,
presentation_id=presentation_id,
)
)
async def _write_log(
provider: str,
model: str,
call_type: str,
input_tokens: Optional[int],
output_tokens: Optional[int],
total_tokens: Optional[int],
duration_ms: Optional[int],
user_id: Optional[uuid.UUID],
client_id: Optional[uuid.UUID],
presentation_id: Optional[uuid.UUID],
) -> None:
try:
from models.sql.ai_usage import AIUsageModel
async with async_session_maker() as session:
entry = AIUsageModel(
provider=provider,
model=model,
call_type=call_type,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens or ((input_tokens or 0) + (output_tokens or 0)),
duration_ms=duration_ms,
user_id=user_id,
client_id=client_id,
presentation_id=presentation_id,
)
session.add(entry)
await session.commit()
except Exception as e:
print(f"AI usage log error: {e}")

View file

@ -264,6 +264,15 @@ class LLMClient:
depth=depth + 1,
)
# Log AI usage
from services import ai_usage_service
usage = getattr(response, "usage", None)
ai_usage_service.log(
provider="openai", model=model, call_type="generate",
input_tokens=getattr(usage, "prompt_tokens", None) if usage else None,
output_tokens=getattr(usage, "completion_tokens", None) if usage else None,
total_tokens=getattr(usage, "total_tokens", None) if usage else None,
)
return response.choices[0].message.content
async def _generate_google(
@ -332,6 +341,15 @@ class LLMClient:
depth=depth + 1,
)
# Log AI usage
from services import ai_usage_service
usage_meta = getattr(response, "usage_metadata", None)
ai_usage_service.log(
provider="google", model=model, call_type="generate",
input_tokens=getattr(usage_meta, "prompt_token_count", None) if usage_meta else None,
output_tokens=getattr(usage_meta, "candidates_token_count", None) if usage_meta else None,
total_tokens=getattr(usage_meta, "total_token_count", None) if usage_meta else None,
)
return text_content
async def _generate_anthropic(
@ -393,6 +411,13 @@ class LLMClient:
depth=depth + 1,
)
# Log AI usage
from services import ai_usage_service
ai_usage_service.log(
provider="anthropic", model=model, call_type="generate",
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
)
return text_content
async def _generate_ollama(
@ -619,7 +644,16 @@ class LLMClient:
depth=depth + 1,
)
if content:
# Log AI usage at the top-level call
if depth == 0:
from services import ai_usage_service
usage = getattr(response, "usage", None)
ai_usage_service.log(
provider="openai", model=model, call_type="generate_structured",
input_tokens=getattr(usage, "prompt_tokens", None) if usage else None,
output_tokens=getattr(usage, "completion_tokens", None) if usage else None,
total_tokens=getattr(usage, "total_tokens", None) if usage else None,
)
return dict(dirtyjson.loads(content))
return content
return None
@ -721,6 +755,15 @@ class LLMClient:
)
if text_content:
# Log AI usage
from services import ai_usage_service
usage_meta = getattr(response, "usage_metadata", None)
ai_usage_service.log(
provider="google", model=model, call_type="generate_structured",
input_tokens=getattr(usage_meta, "prompt_token_count", None) if usage_meta else None,
output_tokens=getattr(usage_meta, "candidates_token_count", None) if usage_meta else None,
total_tokens=getattr(usage_meta, "total_token_count", None) if usage_meta else None,
)
return dict(dirtyjson.loads(text_content))
return None
@ -765,6 +808,13 @@ class LLMClient:
for each in tool_calls:
if each.name == "ResponseSchema":
# Log AI usage
from services import ai_usage_service
ai_usage_service.log(
provider="anthropic", model=model, call_type="generate_structured",
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
)
return each.input
if tool_calls:

View file

@ -28,9 +28,9 @@ from api.v1.ppt.endpoints.pptx_slides import (
extract_fonts_from_oxml,
normalize_font_family_name,
)
from api.v1.ppt.endpoints.slide_to_html import (
generate_html_from_slide,
generate_react_component_from_html,
from api.v1.ppt.endpoints.prompts import (
GENERATE_HTML_SYSTEM_PROMPT,
HTML_TO_REACT_SYSTEM_PROMPT,
)
from services.documents_loader import DocumentsLoader
@ -157,6 +157,172 @@ def _guess_layout_type(layout_name: str) -> str:
return "custom"
def _detect_llm_provider() -> Optional[dict]:
"""Detect which LLM provider is available for vision calls."""
openai_key = os.getenv("OPENAI_API_KEY")
if openai_key:
return {"provider": "openai", "api_key": openai_key}
anthropic_key = os.getenv("ANTHROPIC_API_KEY")
if anthropic_key:
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250514")
return {"provider": "anthropic", "api_key": anthropic_key, "model": model}
google_key = os.getenv("GOOGLE_API_KEY")
if google_key:
return {"provider": "google", "api_key": google_key}
return None
async def _llm_generate_html(
provider: dict, img_b64: str, xml_content: str, fonts: Optional[List[str]]
) -> str:
"""Generate HTML from slide screenshot + OXML using the available LLM provider."""
fonts_text = (
f"\nFONTS (Normalized root families used in this slide, use where required): {', '.join(fonts)}"
if fonts else ""
)
user_text = f"OXML:\n{xml_content[:4000]}\n{fonts_text}"
def _call_openai():
from openai import OpenAI
client = OpenAI(api_key=provider["api_key"])
data_url = f"data:image/png;base64,{img_b64}"
response = client.responses.create(
model="gpt-5",
input=[
{"role": "system", "content": GENERATE_HTML_SYSTEM_PROMPT},
{"role": "user", "content": [
{"type": "input_image", "image_url": data_url},
{"type": "input_text", "text": user_text},
]},
],
reasoning={"effort": "high"},
text={"verbosity": "low"},
)
return getattr(response, "output_text", "") or getattr(response, "text", "") or ""
def _call_anthropic():
import anthropic
client = anthropic.Anthropic(api_key=provider["api_key"])
response = client.messages.create(
model=provider.get("model", "claude-sonnet-4-5-20250514"),
max_tokens=8192,
system=GENERATE_HTML_SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": img_b64,
}},
{"type": "text", "text": user_text},
],
}],
)
return response.content[0].text if response.content else ""
def _call_google():
import google.genai as genai
client = genai.Client(api_key=provider["api_key"])
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=[
GENERATE_HTML_SYSTEM_PROMPT,
{"inline_data": {"mime_type": "image/png", "data": img_b64}},
user_text,
],
)
return response.text or ""
if provider["provider"] == "openai":
return await asyncio.to_thread(_call_openai)
elif provider["provider"] == "anthropic":
return await asyncio.to_thread(_call_anthropic)
elif provider["provider"] == "google":
return await asyncio.to_thread(_call_google)
raise ValueError(f"Unknown provider: {provider['provider']}")
async def _llm_generate_react(
provider: dict, html_content: str, img_b64: str
) -> str:
"""Convert HTML to React TSX component using the available LLM provider."""
user_text = f"HTML INPUT:\n{html_content}"
def _call_openai():
from openai import OpenAI
client = OpenAI(api_key=provider["api_key"])
data_url = f"data:image/png;base64,{img_b64}"
response = client.responses.create(
model="gpt-5",
input=[
{"role": "system", "content": HTML_TO_REACT_SYSTEM_PROMPT},
{"role": "user", "content": [
{"type": "input_image", "image_url": data_url},
{"type": "input_text", "text": user_text},
]},
],
reasoning={"effort": "minimal"},
text={"verbosity": "low"},
)
return getattr(response, "output_text", "") or getattr(response, "text", "") or ""
def _call_anthropic():
import anthropic
client = anthropic.Anthropic(api_key=provider["api_key"])
response = client.messages.create(
model=provider.get("model", "claude-sonnet-4-5-20250514"),
max_tokens=8192,
system=HTML_TO_REACT_SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": img_b64,
}},
{"type": "text", "text": user_text},
],
}],
)
return response.content[0].text if response.content else ""
def _call_google():
import google.genai as genai
client = genai.Client(api_key=provider["api_key"])
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=[
HTML_TO_REACT_SYSTEM_PROMPT,
{"inline_data": {"mime_type": "image/png", "data": img_b64}},
user_text,
],
)
return response.text or ""
if provider["provider"] == "openai":
result = await asyncio.to_thread(_call_openai)
elif provider["provider"] == "anthropic":
result = await asyncio.to_thread(_call_anthropic)
elif provider["provider"] == "google":
result = await asyncio.to_thread(_call_google)
else:
raise ValueError(f"Unknown provider: {provider['provider']}")
# Clean up: remove markdown fences and import/export lines
result = (
result.replace("```tsx", "").replace("```typescript", "")
.replace("```javascript", "").replace("```", "")
)
filtered_lines = []
for line in result.split("\n"):
stripped = line.strip()
if not (stripped.startswith("import ") or stripped.startswith("export ")):
filtered_lines.append(line)
return "\n".join(filtered_lines)
async def parse_master_deck(deck_id: uuid.UUID) -> None:
"""Parse a master deck PPTX asynchronously. Updates DB on completion/failure."""
async with async_session_maker() as session:
@ -181,6 +347,9 @@ async def parse_master_deck(deck_id: uuid.UUID) -> None:
deck.parse_status = "completed"
await session.commit()
# Bridge: register parsed layouts as a custom template
await _register_as_template(deck_id, deck.name, result["layouts"], session)
except Exception as e:
traceback.print_exc()
async with async_session_maker() as session:
@ -225,8 +394,9 @@ async def _do_parse(deck_id: uuid.UUID) -> dict:
pdf_path, temp_dir
)
# Copy screenshots to permanent location
app_data = os.environ.get("APP_DATA_DIRECTORY", os.path.join(os.path.dirname(__file__), "..", "data"))
deck_dir = os.path.join(
os.path.dirname(__file__), "..", "data", "clients",
app_data, "clients",
str(client_id), "master_decks", str(deck_id), "screenshots"
)
os.makedirs(deck_dir, exist_ok=True)
@ -252,15 +422,18 @@ async def _do_parse(deck_id: uuid.UUID) -> dict:
all_fonts.update(normalize_font_family_name(f) for f in raw if f)
# 6. Process each slide layout through LLM pipeline
api_key = os.getenv("OPENAI_API_KEY")
llm_provider = _detect_llm_provider()
layouts_result = []
llm_layouts = min(len(layout_metas), len(screenshots))
print(f"[MasterDeckParser] LLM provider: {llm_provider['provider'] if llm_provider else 'NONE'}")
print(f"[MasterDeckParser] Processing {len(layout_metas)} layouts, {llm_layouts} with screenshots for LLM")
for idx, lm in enumerate(layout_metas):
layout_entry = {
"index": idx,
"layout_name": lm["layout_name"],
"layout_type": _guess_layout_type(lm["layout_name"]),
"xml_snippet": lm["xml_content"][:2000], # Store truncated XML for reference
"xml_snippet": lm["xml_content"][:2000],
"fonts": list(
{normalize_font_family_name(f) for f in extract_fonts_from_oxml(lm["xml_content"]) if f}
),
@ -269,35 +442,31 @@ async def _do_parse(deck_id: uuid.UUID) -> dict:
"screenshot_path": screenshots[idx] if idx < len(screenshots) else None,
}
# Run LLM pipeline if API key available and we have a screenshot
if api_key and idx < len(screenshots) and os.path.exists(screenshots[idx]):
# Run LLM pipeline if provider available and we have a screenshot
if llm_provider and idx < len(screenshots) and os.path.exists(screenshots[idx]):
try:
print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — generating HTML...")
with open(screenshots[idx], "rb") as img_f:
img_b64 = base64.b64encode(img_f.read()).decode("utf-8")
# Step A: Generate HTML from slide screenshot + layout OXML
html = await generate_html_from_slide(
base64_image=img_b64,
media_type="image/png",
xml_content=lm["xml_content"],
api_key=api_key,
fonts=layout_entry["fonts"] or None,
html = await _llm_generate_html(
llm_provider, img_b64, lm["xml_content"],
layout_entry["fonts"] or None,
)
html = html.replace("```html", "").replace("```", "")
layout_entry["html"] = html
# Step B: Generate React component from HTML
react_code = await generate_react_component_from_html(
html_content=html,
api_key=api_key,
image_base64=img_b64,
media_type="image/png",
print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — generating React...")
react_code = await _llm_generate_react(
llm_provider, html, img_b64,
)
react_code = react_code.replace("```tsx", "").replace("```", "")
layout_entry["react_code"] = react_code
print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — done ({len(react_code)} chars)")
except Exception as e:
print(f"LLM pipeline failed for layout {idx} ({lm['layout_name']}): {e}")
print(f"[MasterDeckParser] LLM FAILED for layout {idx} ({lm['layout_name']}): {e}")
traceback.print_exc()
layout_entry["html"] = None
layout_entry["react_code"] = None
@ -315,3 +484,63 @@ async def _do_parse(deck_id: uuid.UUID) -> dict:
"layouts": layouts_result,
"thumbnail_path": thumbnail_path,
}
async def _register_as_template(
deck_id: uuid.UUID,
deck_name: str,
layouts: list,
session,
) -> None:
"""Bridge master deck layouts into the custom template system.
Creates a TemplateModel and PresentationLayoutCodeModel records
so the parsed layouts appear in the template picker during generation.
"""
from models.sql.template import TemplateModel
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from sqlalchemy import select, delete
try:
# Upsert TemplateModel — use deck_id as template id
existing = await session.get(TemplateModel, deck_id)
if existing:
existing.name = deck_name or "Custom Template"
else:
template = TemplateModel(
id=deck_id,
name=deck_name or "Custom Template",
description=f"Auto-generated from master deck: {deck_name}",
)
session.add(template)
# Remove old layout codes for this deck (reparse case)
await session.execute(
delete(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == deck_id
)
)
# Create PresentationLayoutCodeModel for each layout with react_code
for idx, layout in enumerate(layouts):
react_code = layout.get("react_code")
if not react_code:
continue
layout_code = PresentationLayoutCodeModel(
presentation=deck_id,
layout_id=f"layout-{idx}",
layout_name=layout.get("layout_name", f"Layout {idx + 1}"),
layout_code=react_code,
fonts=layout.get("fonts"),
)
session.add(layout_code)
await session.commit()
print(f"Registered master deck {deck_id} as custom template with "
f"{sum(1 for l in layouts if l.get('react_code'))} layouts")
except Exception as e:
print(f"Failed to register master deck as template: {e}")
# Don't fail the entire parse — template registration is non-critical
await session.rollback()

View file

@ -0,0 +1,29 @@
"""Context variable for propagating user/client/presentation info into LLM calls."""
import contextvars
import uuid
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class LLMCallContext:
user_id: Optional[uuid.UUID] = field(default=None)
client_id: Optional[uuid.UUID] = field(default=None)
presentation_id: Optional[uuid.UUID] = field(default=None)
_llm_context: contextvars.ContextVar[Optional[LLMCallContext]] = contextvars.ContextVar(
"llm_call_context", default=None
)
def set_llm_context(ctx: LLMCallContext) -> None:
_llm_context.set(ctx)
def get_llm_context() -> Optional[LLMCallContext]:
return _llm_context.get()
def clear_llm_context() -> None:
_llm_context.set(None)

View file

@ -7,6 +7,12 @@ import os
from arq.connections import RedisSettings
from arq.cron import cron
# Import all SQL models so SQLAlchemy can resolve FK references
from models.sql.user import UserModel # noqa: F401
from models.sql.client import ClientModel # noqa: F401
from models.sql.team import TeamModel # noqa: F401
from models.sql.presentation import PresentationModel # noqa: F401
from workers.master_deck_worker import parse_master_deck_task
from workers.presentation_worker import generate_presentation_task
from workers.retention_worker import retention_cleanup_task, retention_purge_task
@ -25,6 +31,6 @@ class WorkerSettings:
cron(retention_purge_task, weekday=0, hour=3, minute=0), # Weekly Monday 3:00 AM
]
max_jobs = 5
job_timeout = 600 # 10 minutes
job_timeout = 1800 # 30 minutes (master deck parsing with LLM is slow)
max_tries = 3
health_check_interval = 30

View file

@ -9,7 +9,7 @@ from services.master_deck_parser_service import parse_master_deck
from services.redis_service import publish_job_progress
async def parse_master_deck_task(ctx: dict, job_id: str) -> None:
async def parse_master_deck_task(ctx: dict, job_id: str, deck_id: str = "") -> None:
"""ARQ task: parse a master deck via the existing parser service."""
job_uuid = uuid.UUID(job_id)
@ -30,9 +30,8 @@ async def parse_master_deck_task(ctx: dict, job_id: str) -> None:
except Exception:
pass
# The existing parser updates MasterDeckModel directly
# presentation_id is reused to store the deck_id for this job type
await parse_master_deck(job.presentation_id)
# Parse the master deck by its own ID
await parse_master_deck(uuid.UUID(deck_id))
job.status = "completed"
job.progress = 100

View file

@ -58,6 +58,7 @@ services:
REDIS_URL: redis://redis:6379/0
APP_DATA_DIRECTORY: /app_data
TEMP_DIRECTORY: /tmp/deckforge
PYTHONUNBUFFERED: "1"
volumes:
- app_data:/app_data
depends_on:
@ -74,6 +75,7 @@ services:
- "3000:3000"
environment:
CAN_CHANGE_KEYS: "false"
API_INTERNAL_URL: "http://api:8000"
depends_on:
- api

View file

@ -4,6 +4,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV API_INTERNAL_URL=http://api:8000
RUN npm run build
FROM node:20-alpine

View file

@ -1,6 +1,8 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '@/store/store';
import {
BarChart3,
Users,
@ -10,15 +12,10 @@ import {
AlertTriangle,
CheckCircle2,
Loader2,
Cpu,
Zap,
} from 'lucide-react';
import { Card } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
import { cn } from '@/lib/utils';
@ -47,6 +44,16 @@ interface PerformanceData {
total_jobs: number;
}
interface AIUsageData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
by_provider: { provider: string; calls: number; tokens: number }[];
by_model: { model: string; calls: number; tokens: number }[];
daily: { date: string; calls: number; tokens: number }[];
}
async function fetchAnalytics(endpoint: string, clientId?: string) {
const params = clientId ? `?client_id=${clientId}` : '';
const response = await fetch(`/api/v1/admin/analytics/${endpoint}${params}`, {
@ -141,32 +148,44 @@ function StatusBar({ distribution }: { distribution: Record<string, number> }) {
);
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
export default function AnalyticsPage() {
const user = useSelector((state: RootState) => state.auth.user);
const [overview, setOverview] = useState<OverviewData | null>(null);
const [usage, setUsage] = useState<UsageData | null>(null);
const [quality, setQuality] = useState<QualityData | null>(null);
const [performance, setPerformance] = useState<PerformanceData | null>(null);
const [aiUsage, setAiUsage] = useState<AIUsageData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [clientId, setClientId] = useState<string>('');
// Auto-filter by user's clientId
const clientId = user?.clientId || undefined;
useEffect(() => {
if (!user) return;
loadAll();
}, [clientId]);
}, [user?.clientId]);
const loadAll = async () => {
setIsLoading(true);
const cid = clientId || undefined;
try {
const [o, u, q, p] = await Promise.all([
fetchAnalytics('overview', cid),
fetchAnalytics('usage', cid),
fetchAnalytics('quality', cid),
fetchAnalytics('performance', cid),
const [o, u, q, p, ai] = await Promise.all([
fetchAnalytics('overview', clientId),
fetchAnalytics('usage', clientId),
fetchAnalytics('quality', clientId),
fetchAnalytics('performance', clientId),
fetchAnalytics('ai-usage', clientId).catch(() => null),
]);
setOverview(o);
setUsage(u);
setQuality(q);
setPerformance(p);
setAiUsage(ai);
} catch (e) {
console.error('Analytics load error:', e);
} finally {
@ -225,13 +244,11 @@ export default function AnalyticsPage() {
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Usage Chart */}
<Card className="p-5">
<h3 className="text-sm font-semibold mb-4">Presentations per Day</h3>
{usage ? <MiniBarChart data={usage.daily} /> : <p className="text-gray-400">Loading...</p>}
</Card>
{/* Status Distribution */}
<Card className="p-5">
<h3 className="text-sm font-semibold mb-4">Status Distribution</h3>
{quality ? (
@ -269,6 +286,60 @@ export default function AnalyticsPage() {
</div>
)}
{/* AI Usage Section */}
{aiUsage && (
<>
<h2 className="text-lg font-semibold mt-2">AI Model Usage</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Total AI Calls"
value={aiUsage.total_calls}
icon={Cpu}
color="text-purple-600"
/>
<StatCard
title="Input Tokens"
value={formatTokens(aiUsage.total_input_tokens)}
icon={Zap}
color="text-blue-600"
/>
<StatCard
title="Output Tokens"
value={formatTokens(aiUsage.total_output_tokens)}
icon={Zap}
color="text-green-600"
/>
</div>
{aiUsage.by_provider.length > 0 && (
<Card className="p-5">
<h3 className="text-sm font-semibold mb-4">Usage by Provider</h3>
<div className="space-y-2">
{aiUsage.by_provider.map((p) => {
const maxCalls = Math.max(...aiUsage.by_provider.map((x) => x.calls), 1);
return (
<div key={p.provider} className="flex items-center gap-3">
<span className="text-sm font-medium w-24 capitalize">{p.provider}</span>
<div className="flex-1">
<div
className="h-6 bg-purple-100 rounded flex items-center px-2"
style={{ width: `${(p.calls / maxCalls) * 100}%`, minWidth: 60 }}
>
<span className="text-xs font-medium">{p.calls} calls</span>
</div>
</div>
<span className="text-xs text-gray-400 w-20 text-right">
{formatTokens(p.tokens)} tokens
</span>
</div>
);
})}
</div>
</Card>
)}
</>
)}
{/* Top Users */}
{usage && usage.top_users.length > 0 && (
<Card className="p-5">

View file

@ -335,29 +335,38 @@ function DeckCard({
{layouts.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{layouts.map((layout, idx) => (
<button
key={idx}
onClick={() => onEditLayout(idx, layout)}
className="text-left bg-gray-50 rounded-lg border p-3 hover:border-[#5146E5] hover:bg-[#5146E5]/5 transition-colors group"
>
<div className="flex items-center gap-2 mb-1">
<FileCode className="w-4 h-4 text-gray-400 group-hover:text-[#5146E5]" />
<span className="text-xs font-medium truncate">
{(layout.layout_name as string) || `Layout ${idx + 1}`}
</span>
</div>
<span className="text-[10px] text-gray-400 px-1.5 py-0.5 bg-gray-200 rounded">
{(layout.layout_type as string) || 'custom'}
</span>
{Boolean(layout.react_code) && (
<p className="text-[10px] text-green-600 mt-1">React code ready</p>
)}
{!Boolean(layout.react_code) && deck.parse_status === 'completed' && (
<p className="text-[10px] text-gray-400 mt-1">No code generated</p>
)}
</button>
))}
{layouts.map((layout, idx) => {
const screenshotPath = layout.screenshot_path as string | undefined;
const screenshotFilename = screenshotPath ? screenshotPath.split('/').pop() : null;
return (
<button
key={idx}
onClick={() => onEditLayout(idx, layout)}
className="text-left bg-gray-50 rounded-lg border p-2 hover:border-[#5146E5] hover:bg-[#5146E5]/5 transition-colors group overflow-hidden"
>
{screenshotFilename ? (
<img
src={`/api/v1/admin/master-decks/${deck.id}/screenshot/${screenshotFilename}`}
alt={(layout.layout_name as string) || `Layout ${idx + 1}`}
className="w-full h-24 object-cover rounded mb-2 bg-white"
loading="lazy"
/>
) : (
<div className="w-full h-24 flex items-center justify-center bg-gray-100 rounded mb-2">
<FileCode className="w-8 h-8 text-gray-300" />
</div>
)}
<div className="px-1">
<p className="text-xs font-medium truncate">
{(layout.layout_name as string) || `Layout ${idx + 1}`}
</p>
<span className="text-[10px] text-gray-400 px-1.5 py-0.5 bg-gray-200 rounded inline-block mt-1">
{(layout.layout_type as string) || 'custom'}
</span>
</div>
</button>
);
})}
</div>
)}
</div>

View file

@ -8,9 +8,7 @@ import { Separator } from '@/components/ui/separator';
import {
Users,
Building2,
Users2,
Layers,
Palette,
HardDrive,
FileText,
BarChart3,
Settings,
@ -27,7 +25,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ label: 'Users', href: '/admin/users', icon: <Users className="w-4 h-4" />, roles: ['super_admin'] },
{ label: 'Clients', href: '/admin/clients', icon: <Building2 className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Teams', href: '/admin/clients', icon: <Users2 className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Storage', href: '/admin/storage', icon: <HardDrive className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Audit Log', href: '/admin/audit', icon: <FileText className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Analytics', href: '/admin/analytics', icon: <BarChart3 className="w-4 h-4" />, roles: ['super_admin', 'client_admin'] },
{ label: 'Settings', href: '/admin/settings', icon: <Settings className="w-4 h-4" />, roles: ['super_admin'] },

View file

@ -1,17 +1,283 @@
'use client';
import { Settings } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import {
Settings,
Loader2,
Check,
Cpu,
Image as ImageIcon,
Key,
} from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
import { toast } from 'sonner';
interface SystemSettings {
llm_provider: string;
llm_model: string;
image_provider: string;
anthropic_api_key_set: boolean;
openai_api_key_set: boolean;
google_api_key_set: boolean;
available_llm_providers: string[];
available_image_providers: string[];
}
const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
google: 'Google (Gemini)',
ollama: 'Ollama (Local)',
custom: 'Custom',
'dall-e-3': 'DALL-E 3',
'gpt-image-1.5': 'GPT Image 1.5',
pexels: 'Pexels (Stock)',
pixabay: 'Pixabay (Stock)',
comfyui: 'ComfyUI',
};
export default function SettingsPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">System Settings</h1>
<div className="flex items-center justify-center h-64 border-2 border-dashed border-gray-300 rounded-lg">
<div className="text-center text-gray-400">
<Settings className="w-10 h-10 mx-auto mb-2" />
<p>System settings (LLM config, image providers) will be available here.</p>
const [settings, setSettings] = useState<SystemSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [llmProvider, setLlmProvider] = useState('');
const [llmModel, setLlmModel] = useState('');
const [imageProvider, setImageProvider] = useState('');
const [anthropicKey, setAnthropicKey] = useState('');
const [openaiKey, setOpenaiKey] = useState('');
const [googleKey, setGoogleKey] = useState('');
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/v1/admin/settings', { headers: getHeader() });
if (res.status === 403) {
setError('Super admin access required');
return;
}
if (!res.ok) throw new Error('Failed to load settings');
const data: SystemSettings = await res.json();
setSettings(data);
setLlmProvider(data.llm_provider);
setLlmModel(data.llm_model);
setImageProvider(data.image_provider);
} catch (e) {
setError('Failed to load settings');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const body: Record<string, string> = {};
if (llmProvider !== settings?.llm_provider) body.llm_provider = llmProvider;
if (llmModel !== settings?.llm_model) body.llm_model = llmModel;
if (imageProvider !== settings?.image_provider) body.image_provider = imageProvider;
if (anthropicKey) body.anthropic_api_key = anthropicKey;
if (openaiKey) body.openai_api_key = openaiKey;
if (googleKey) body.google_api_key = googleKey;
if (Object.keys(body).length === 0) {
toast.info('No changes to save');
setSaving(false);
return;
}
const res = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: { ...getHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Save failed');
}
toast.success('Settings saved');
setAnthropicKey('');
setOpenaiKey('');
setGoogleKey('');
loadSettings();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Save failed');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">System Settings</h1>
<div className="flex items-center justify-center h-64 border-2 border-dashed border-gray-300 rounded-lg">
<div className="text-center text-gray-400">
<Settings className="w-10 h-10 mx-auto mb-2" />
<p>{error}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">System Settings</h1>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
Save Changes
</Button>
</div>
<p className="text-sm text-gray-500">
Runtime configuration. Changes apply immediately but don&apos;t persist across container restarts.
For permanent changes, update environment variables in .env / docker-compose.yml.
</p>
{/* LLM Configuration */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Cpu className="w-5 h-5 text-[#5146E5]" />
<h2 className="text-lg font-semibold">LLM Provider</h2>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Provider</Label>
<Select value={llmProvider} onValueChange={setLlmProvider}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{settings?.available_llm_providers.map((p) => (
<SelectItem key={p} value={p}>
{PROVIDER_LABELS[p] || p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Model</Label>
<Input
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder="e.g. claude-sonnet-4-6"
/>
</div>
</div>
</Card>
{/* Image Generation */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="w-5 h-5 text-green-600" />
<h2 className="text-lg font-semibold">Image Generation</h2>
</div>
<div className="space-y-1">
<Label>Provider</Label>
<Select value={imageProvider} onValueChange={setImageProvider}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{settings?.available_image_providers.map((p) => (
<SelectItem key={p} value={p}>
{PROVIDER_LABELS[p] || p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Card>
{/* API Keys */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Key className="w-5 h-5 text-orange-600" />
<h2 className="text-lg font-semibold">API Keys</h2>
</div>
<p className="text-xs text-gray-400">
Leave blank to keep existing keys. Enter a new value to update.
</p>
<div className="space-y-3">
<div className="space-y-1">
<Label className="flex items-center gap-2">
Anthropic API Key
{settings?.anthropic_api_key_set && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
)}
</Label>
<Input
type="password"
value={anthropicKey}
onChange={(e) => setAnthropicKey(e.target.value)}
placeholder={settings?.anthropic_api_key_set ? '••••••••••••' : 'sk-ant-...'}
/>
</div>
<div className="space-y-1">
<Label className="flex items-center gap-2">
OpenAI API Key
{settings?.openai_api_key_set && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
)}
</Label>
<Input
type="password"
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
placeholder={settings?.openai_api_key_set ? '••••••••••••' : 'sk-...'}
/>
</div>
<div className="space-y-1">
<Label className="flex items-center gap-2">
Google API Key
{settings?.google_api_key_set && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
)}
</Label>
<Input
type="password"
value={googleKey}
onChange={(e) => setGoogleKey(e.target.value)}
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
/>
</div>
</div>
</Card>
</div>
);
}

View file

@ -0,0 +1,252 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '@/store/store';
import {
HardDrive,
FileText,
Download,
Trash2,
Loader2,
FolderOpen,
} from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
import { toast } from 'sonner';
interface StorageSummary {
total_presentations: number;
total_files: number;
total_size_bytes: number;
}
interface StoragePresentation {
id: string;
title: string | null;
status: string;
created_at: string | null;
file_count: number;
total_size_bytes: number;
has_export: boolean;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-yellow-100 text-yellow-700',
in_review: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
};
export default function StoragePage() {
const user = useSelector((state: RootState) => state.auth.user);
const clientId = user?.clientId || undefined;
const [summary, setSummary] = useState<StorageSummary | null>(null);
const [presentations, setPresentations] = useState<StoragePresentation[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<StoragePresentation | null>(null);
const load = useCallback(async () => {
if (!clientId) return;
setLoading(true);
try {
const params = `?client_id=${clientId}`;
const headers = getHeader();
const [summaryRes, presRes] = await Promise.all([
fetch(`/api/v1/admin/storage/summary${params}`, { headers }),
fetch(`/api/v1/admin/storage/presentations${params}`, { headers }),
]);
if (summaryRes.ok) setSummary(await summaryRes.json());
if (presRes.ok) setPresentations(await presRes.json());
} catch (e) {
console.error('Storage load error:', e);
} finally {
setLoading(false);
}
}, [clientId]);
useEffect(() => {
load();
}, [load]);
const handleDownload = (id: string) => {
window.open(`/api/v1/admin/storage/presentations/${id}/download`, '_blank');
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
const res = await fetch(`/api/v1/admin/storage/presentations/${deleteTarget.id}`, {
method: 'DELETE',
headers: getHeader(),
});
if (res.ok) {
toast.success('Presentation deleted');
setDeleteTarget(null);
load();
} else {
toast.error('Failed to delete');
}
} catch {
toast.error('Failed to delete');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Storage</h1>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">Presentations</p>
<p className="text-2xl font-bold mt-1">{summary.total_presentations}</p>
</div>
<div className="p-2 rounded-lg bg-gray-50 text-[#5146E5]">
<FileText className="w-5 h-5" />
</div>
</div>
</Card>
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">Export Files</p>
<p className="text-2xl font-bold mt-1">{summary.total_files}</p>
</div>
<div className="p-2 rounded-lg bg-gray-50 text-blue-600">
<FolderOpen className="w-5 h-5" />
</div>
</div>
</Card>
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">Total Size</p>
<p className="text-2xl font-bold mt-1">{formatBytes(summary.total_size_bytes)}</p>
</div>
<div className="p-2 rounded-lg bg-gray-50 text-green-600">
<HardDrive className="w-5 h-5" />
</div>
</div>
</Card>
</div>
)}
{/* Presentations Table */}
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<th className="text-left p-3 font-medium text-gray-600">Title</th>
<th className="text-left p-3 font-medium text-gray-600">Status</th>
<th className="text-left p-3 font-medium text-gray-600">Created</th>
<th className="text-right p-3 font-medium text-gray-600">Files</th>
<th className="text-right p-3 font-medium text-gray-600">Size</th>
<th className="text-right p-3 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody>
{presentations.length === 0 ? (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-400">
No presentations found.
</td>
</tr>
) : (
presentations.map((p) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="p-3">
<span className="font-medium">{p.title || 'Untitled'}</span>
</td>
<td className="p-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[p.status] || 'bg-gray-100 text-gray-600'}`}>
{p.status}
</span>
</td>
<td className="p-3 text-gray-500">
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
</td>
<td className="p-3 text-right text-gray-500">{p.file_count}</td>
<td className="p-3 text-right text-gray-500">
{p.total_size_bytes > 0 ? formatBytes(p.total_size_bytes) : '—'}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
{p.has_export && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(p.id)}
title="Download PPTX"
>
<Download className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(p)}
title="Delete"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
{/* Delete Confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Presentation</DialogTitle>
</DialogHeader>
<p className="text-sm text-gray-500">
Are you sure you want to delete &quot;{deleteTarget?.title || 'Untitled'}&quot;?
Associated files will be cleaned up by the retention service.
</p>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,15 +1,21 @@
const API_URL = process.env.API_INTERNAL_URL || 'http://localhost:8000';
const nextConfig = {
reactStrictMode: false,
distDir: ".next-build",
// Rewrites for development - proxy font requests to FastAPI backend
// Proxy API and static asset requests to FastAPI backend
async rewrites() {
return [
{
source: '/app_data/fonts/:path*',
destination: 'http://localhost:8000/app_data/fonts/:path*',
source: '/api/v1/:path*',
destination: `${API_URL}/api/v1/:path*`,
},
{
source: '/app_data/:path*',
destination: `${API_URL}/app_data/:path*`,
},
];
},

View file

@ -5,6 +5,7 @@ export interface User {
email: string;
displayName: string;
role: "super_admin" | "client_admin" | "user";
clientId?: string | null;
}
interface AuthState {