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:
parent
2ecacacb3f
commit
d3d1667a79
30 changed files with 1845 additions and 186 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
128
backend/api/v1/admin/settings_router.py
Normal file
128
backend/api/v1/admin/settings_router.py
Normal 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, ""))
|
||||
191
backend/api/v1/admin/storage_router.py
Normal file
191
backend/api/v1/admin/storage_router.py
Normal 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}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
36
backend/models/sql/ai_usage.py
Normal file
36
backend/models/sql/ai_usage.py
Normal 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
|
||||
),
|
||||
)
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
80
backend/services/ai_usage_service.py
Normal file
80
backend/services/ai_usage_service.py
Normal 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}")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
29
backend/utils/llm_context.py
Normal file
29
backend/utils/llm_context.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
252
frontend/app/admin/storage/page.tsx
Normal file
252
frontend/app/admin/storage/page.tsx
Normal 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 "{deleteTarget?.title || 'Untitled'}"?
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface User {
|
|||
email: string;
|
||||
displayName: string;
|
||||
role: "super_admin" | "client_admin" | "user";
|
||||
clientId?: string | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue