diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 8ab5b19..b437b17 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -419,3 +419,73 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers def create_export_job(*, timeline, user) -> ExportJob: return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED) + + +_STANDALONE_CATEGORY = { + "model": Asset.Category.PERSON, + "cover": Asset.Category.PRODUCT_IMAGE, + "image": Asset.Category.PRODUCT_IMAGE, +} +_STANDALONE_TASK_TYPE = { + "model": AITask.Type.PERSON_IMAGE, + "cover": AITask.Type.PRODUCT_IMAGE, + "image": AITask.Type.PRODUCT_IMAGE, +} + + +def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", count: int = 1) -> list[Asset]: + """不绑定项目的独立生图(图片创作 / 模特上身图 / 平台套图)。复用项目内生图链路,AITask.project=None。""" + model_config = get_default_model(ModelConfig.Capability.IMAGE) + if model_config is None: + raise ValueError("no active image model configured") + category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED) + task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE) + count = max(1, min(int(count or 1), 4)) + provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None) + assets: list[Asset] = [] + for index in range(count): + cost = estimate_cost(model_config) + task = AITask.objects.create( + team=team, + created_by=user, + project=None, + task_type=task_type, + status=AITask.Status.CREATED, + model_config=model_config, + idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}", + request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode}, + estimated_cost=cost, + ) + reserve_credit(team=team, user=user, task=task, amount=cost) + task.status = AITask.Status.RESERVED + task.save(update_fields=["status", "updated_at"]) + reservation = task.credit_reservation + try: + response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt) + media = provider.extract_first_media_url(response) + with transaction.atomic(): + task.status = AITask.Status.SUCCEEDED + task.response_payload = response + task.actual_cost = task.estimated_cost + task.completed_at = timezone.now() + task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) + charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) + fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) + suffix = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png") + asset_id = uuid.uuid4() + object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}" + stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type) + asset = Asset.objects.create( + id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}", + asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task, + ) + AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True) + assets.append(asset) + except Exception as exc: + task.status = AITask.Status.FAILED + task.error_message = str(exc) + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + release_credit(reservation=reservation, reason=str(exc)) + raise + return assets diff --git a/core/backend/apps/ai/urls.py b/core/backend/apps/ai/urls.py index 16ce9a5..cb09450 100644 --- a/core/backend/apps/ai/urls.py +++ b/core/backend/apps/ai/urls.py @@ -1,9 +1,12 @@ +from django.urls import path from rest_framework.routers import DefaultRouter -from .views import AITaskViewSet, ModelConfigViewSet +from .views import AITaskViewSet, GenerateImageView, ModelConfigViewSet router = DefaultRouter() router.register("tasks", AITaskViewSet, basename="ai-task") router.register("models", ModelConfigViewSet, basename="model-config") -urlpatterns = router.urls +urlpatterns = [ + path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"), +] + router.urls diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index a632e92..417ab4d 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1,9 +1,36 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework.viewsets import ReadOnlyModelViewSet -from apps.common.api import TeamScopedViewSetMixin +from apps.assets.serializers import AssetSerializer +from apps.common.api import TeamScopedViewSetMixin, get_current_team from .models import AITask, ModelConfig from .serializers import AITaskSerializer, ModelConfigSerializer +from .services import generate_standalone_image + + +class GenerateImageView(APIView): + """POST /api/ai/generate-image/ — 独立生图(不绑项目)· 图片创作/模特图/平台套图共用。""" + + def post(self, request): + prompt = str(request.data.get("prompt") or "").strip() + if not prompt: + return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST) + mode = str(request.data.get("mode") or "image") + try: + count = int(request.data.get("count") or 1) + except (TypeError, ValueError): + count = 1 + team = get_current_team(request.user) + try: + assets = generate_standalone_image(team=team, user=request.user, prompt=prompt, mode=mode, count=count) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: # noqa: BLE001 — 生成失败已回滚额度,返回明确错误给前端 + return Response({"detail": f"生成失败: {exc}"}, status=status.HTTP_502_BAD_GATEWAY) + return Response({"assets": AssetSerializer(assets, many=True).data}, status=status.HTTP_201_CREATED) class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index e707ee1..48c2b89 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -66,6 +66,15 @@ export const api = { me() { return request<{ user: User; team: Team }>("/api/auth/me/"); }, + updateProfile(payload: { name?: string; phone?: string; email?: string }) { + return request<{ user: User; team: Team }>("/api/auth/me/", { method: "PATCH", body: JSON.stringify(payload) }); + }, + changePassword(payload: { old_password: string; new_password: string }) { + return request<{ token: string }>("/api/auth/me/password/", { method: "POST", body: JSON.stringify(payload) }); + }, + uploadAvatar(formData: FormData) { + return request("/api/auth/me/avatar/", { method: "POST", body: formData }); + }, logout() { return request("/api/auth/logout/", { method: "POST" }); }, @@ -183,6 +192,9 @@ export const api = { aiTasks() { return request>("/api/ai/tasks/"); }, + generateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) { + return request<{ assets: Asset[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) }); + }, recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) { return request("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) }); },