生图模型可选(火山/gpt-image)+ 模特上身图提示词强化 + 多会话等改动
本轮(生图模型选择 + 火山接入): - 工作室新增「生图模型」选择器(模特上身图/平台套图头部 chip + 图片创作底部 Pill), 默认火山 Seedream,可切 gpt-image-2;选择写入 localStorage,下次进页面读回 - 后端 resolve_image_model 解析所选模型;enqueue_standalone_images 接 image_model - worker 按模型能力分流:有 image_edit(gpt-image)走多图编辑;无(火山)走 image_generation(image=参考图);新增 _ratio_to_volcano_size 让火山按比例出图 → 内衣等敏感品类用火山可绕开 gpt-image 的 sexual 内容审核 模特上身图提示词: - 穿戴/非穿戴分流、按 index 变化动作场景镜头、负面词尾接、多图参考序号自适应 - _product_reference_urls:商品参考真实上传图优先、排除 AI 生成图、可多张 其他(并入此前各会话未提交改动): - 图片创作多会话(ImageConversation + migration 0019)、任务中心按类型过滤 - accounts/projects/assets/team/auth 等零散调整、相关测试 - 测试脚本(tryon_*.py)、测试清单(core/bug/*.xlsx)
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from apps.assets.serializers import AssetSerializer
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.celery_health import require_worker
|
||||
|
||||
from .models import AITask, ModelConfig
|
||||
from .serializers import AITaskSerializer, ModelConfigSerializer
|
||||
from .models import AITask, ImageConversation, ModelConfig
|
||||
from .serializers import AITaskSerializer, ImageConversationSerializer, ModelConfigSerializer
|
||||
from .services import enqueue_standalone_images
|
||||
|
||||
|
||||
@@ -35,13 +38,33 @@ class GenerateImageView(APIView):
|
||||
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
|
||||
ratio = str(request.data.get("ratio") or "").strip() or None
|
||||
image_model = str(request.data.get("image_model") or "").strip() or None
|
||||
conversation_id = str(request.data.get("conversation_id") or "").strip() or None
|
||||
# 用户在图片创作里上传的参考图(已先传成 Asset),按 id 列表带入 → 生成时作多图参考(image_edit)
|
||||
raw_refs = request.data.get("reference_image_ids") or []
|
||||
if isinstance(raw_refs, str):
|
||||
raw_refs = [s for s in raw_refs.split(",") if s.strip()]
|
||||
reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()]
|
||||
team = get_current_team(request.user)
|
||||
# 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。
|
||||
conversation = None
|
||||
if conversation_id:
|
||||
conversation = ImageConversation.objects.filter(team=team, id=conversation_id, is_deleted=False).first()
|
||||
if conversation is None:
|
||||
conversation = ImageConversation.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
mode=mode if mode in dict(ImageConversation.Mode.choices) else ImageConversation.Mode.IMAGE,
|
||||
product_id=product_id,
|
||||
title=(prompt[:24] or "默认创作"),
|
||||
)
|
||||
try:
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model)
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids)
|
||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# 本次提交即刷新对话活跃时间,左栏「最近」据此置顶
|
||||
ImageConversation.objects.filter(id=conversation.id).update(last_active_at=timezone.now())
|
||||
return Response(
|
||||
{"tasks": [{"id": str(t.id), "status": t.status} for t in tasks]},
|
||||
{"conversation_id": str(conversation.id), "tasks": [{"id": str(t.id), "status": t.status} for t in tasks]},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
@@ -82,6 +105,13 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
# 可选 ?task_type=a,b,c 过滤:生图工作室的任务中心只想看生图任务(模特上身图/平台套图/
|
||||
# 图片创作 = person_image / product_image),不掺脚本/实体抽取/故事板等流水线内部任务。
|
||||
queryset = super().get_queryset()
|
||||
# 从 request_payload(已 defer)里只抽 batch_id / mode 两个 JSON 标量供前端「按批分组 + 标签」用:
|
||||
# KeyTextTransform 在 SQL 层 JSON_EXTRACT,不会把几 MB 的 payload 整列拉回(避开 payload 性能坑)。
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
queryset = queryset.annotate(
|
||||
rp_batch_id=KeyTextTransform("batch_id", "request_payload"),
|
||||
rp_mode=KeyTextTransform("mode", "request_payload"),
|
||||
)
|
||||
raw = self.request.query_params.get("task_type", "").strip()
|
||||
if raw:
|
||||
types = [t.strip() for t in raw.split(",") if t.strip()]
|
||||
@@ -90,6 +120,73 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
return queryset
|
||||
|
||||
|
||||
class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""图片创作工作室的「对话」CRUD。
|
||||
list 按 ?mode= 过滤、排除软删、按 last_active_at 倒序(左栏「最近」);
|
||||
create 开新对话;partial_update 重命名;destroy 软删(不连带删图)。
|
||||
detail action `tasks` 返回该对话下的生图任务 + 成图 asset,供切换对话时回填批次流。
|
||||
"""
|
||||
|
||||
serializer_class = ImageConversationSerializer
|
||||
queryset = ImageConversation.objects.filter(is_deleted=False).select_related("product").order_by("-last_active_at")
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
|
||||
mode = self.request.query_params.get("mode", "").strip()
|
||||
if mode:
|
||||
queryset = queryset.filter(mode=mode)
|
||||
return queryset
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# 软删:对话从列表消失,但其 AITask.conversation 置空由 DB on_delete=SET_NULL 不触发(我们没真删),
|
||||
# 成图始终留在资产库。仅打标记。
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def tasks(self, request, pk=None):
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.serializers import _asset_preview
|
||||
|
||||
conversation = self.get_object()
|
||||
tasks = (
|
||||
AITask.objects.filter(conversation=conversation)
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
.order_by("created_at")
|
||||
)
|
||||
# 参考图 id → {name,url}:跨任务可能重复,缓存一次解析,供切换/刷新后批次头回显「参考了哪些图」
|
||||
ref_cache: dict[str, dict] = {}
|
||||
|
||||
def resolve_refs(ids):
|
||||
out = []
|
||||
for rid in ids or []:
|
||||
rid = str(rid)
|
||||
if rid not in ref_cache:
|
||||
a = Asset.objects.filter(id=rid).prefetch_related("files").first()
|
||||
ref_cache[rid] = {"name": a.name, "url": _asset_preview(a)} if a else None
|
||||
if ref_cache[rid]:
|
||||
out.append(ref_cache[rid])
|
||||
return out
|
||||
|
||||
data = [
|
||||
{
|
||||
"id": str(t.id),
|
||||
"status": t.status,
|
||||
"error_message": t.error_message,
|
||||
"prompt": (t.request_payload or {}).get("prompt", ""),
|
||||
"batch_id": (t.request_payload or {}).get("batch_id", ""),
|
||||
"ratio": (t.request_payload or {}).get("ratio") or "",
|
||||
"reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")),
|
||||
"created_at": t.created_at,
|
||||
"assets": AssetSerializer(
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||
).data,
|
||||
}
|
||||
for t in tasks
|
||||
]
|
||||
return Response({"conversation_id": str(conversation.id), "tasks": data})
|
||||
|
||||
|
||||
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
|
||||
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
|
||||
|
||||
Reference in New Issue
Block a user