生图模型可选(火山/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:
zyc
2026-06-27 14:00:28 +08:00
parent 0a519ca892
commit ce7eaefe1d
21 changed files with 1279 additions and 158 deletions
+114 -1
View File
@@ -175,7 +175,10 @@ from unittest.mock import patch
from django.test import TestCase
from apps.accounts.models import Team, User
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ImageConversation, ModelConfig
from apps.ai.providers.volcano import VolcanoArkProvider
from apps.ai.services import enqueue_standalone_images
from apps.assets.models import Asset, AssetFile
@@ -245,6 +248,116 @@ class StandaloneImageReferenceTests(TestCase):
prov.image_generation.assert_called_once()
prov.image_edit.assert_not_called()
def test_image_mode_uses_uploaded_reference_images(self):
"""图片创作自由模式:用户上传的参考图必须作为 image_edit 的参考图传进去,
而不是被忽略走纯文生图——回归保护 #图片创作没参考我上传的素材# 这个最致命的 bug。"""
prov = self._patch_provider()
ref = Asset.objects.create(
team=self.team, created_by=self.user, name="背心参考", asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD, category=Asset.Category.UPLOAD,
)
AssetFile.objects.create(asset=ref, object_key="r.png", bucket="b", content_type="image/png", preview_url="http://x/ref.png", is_primary=True)
enqueue_standalone_images(
team=self.team, user=self.user, prompt="按这件背心生成不同场景的穿搭",
mode="image", count=1, ratio="1:1", reference_image_ids=[str(ref.id)],
)
prov.image_edit.assert_called_once()
self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/ref.png"])
# 提示词必须显式钉住「保留参考图主体」+ 带上用户原话(否则图生图不真的还原上传素材)
used_prompt = prov.image_edit.call_args.kwargs["prompt"]
self.assertIn("按这件背心生成不同场景的穿搭", used_prompt)
self.assertIn("参考图", used_prompt)
self.assertIn("严格保留", used_prompt)
prov.image_generation.assert_not_called()
class ImageConversationTests(TestCase):
"""图片创作「对话」实体:CRUD + 团队隔离 + 软删 + 生图自动归属对话 + 任务回填。"""
def setUp(self):
self.user = User.objects.create_user(username="convowner", password="pass")
self.team = Team.objects.create(name="ConvT", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_crud_and_listing(self):
# 新建
r = self.client.post("/api/ai/image-conversations/", {"mode": "image", "title": "测试创作"}, format="json")
self.assertEqual(r.status_code, 201, r.content)
conv_id = r.json()["id"]
# 列出(只 image 模式)
r = self.client.get("/api/ai/image-conversations/?mode=image")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.json()["results"]), 1)
# 重命名
r = self.client.patch(f"/api/ai/image-conversations/{conv_id}/", {"title": "改后"}, format="json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["title"], "改后")
# 软删:列表消失,但 DB 记录仍在(is_deleted=True)
r = self.client.delete(f"/api/ai/image-conversations/{conv_id}/")
self.assertIn(r.status_code, (204, 200))
self.assertEqual(self.client.get("/api/ai/image-conversations/?mode=image").json()["results"], [])
self.assertTrue(ImageConversation.objects.get(id=conv_id).is_deleted)
def test_team_isolation(self):
other = User.objects.create_user(username="other", password="pass")
other_team = Team.objects.create(name="Other", owner=other)
ImageConversation.objects.create(team=other_team, created_by=other, title="别人的")
r = self.client.get("/api/ai/image-conversations/?mode=image")
self.assertEqual(r.json()["results"], [])
def test_generate_auto_creates_and_binds_conversation(self):
# 余额 + 默认图像模型(迁移已 seed),provider/worker 全 mock,聚焦验证「任务挂到对话」
CreditAccount.objects.create(team=self.team, balance="100.0000")
patch("apps.ai.tasks.generate_standalone_image_task.delay").start()
self.addCleanup(patch.stopall)
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="目标对话")
tasks = enqueue_standalone_images(team=self.team, user=self.user, prompt="一只猫", mode="image", count=2, conversation=conv)
self.assertEqual(len(tasks), 2)
self.assertTrue(all(t.conversation_id == conv.id for t in tasks))
self.assertEqual(conv.tasks.count(), 2)
def test_tasks_endpoint_returns_grouped_history(self):
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="历史")
mc = ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE).first()
AITask.objects.create(
team=self.team, created_by=self.user, conversation=conv, task_type=AITask.Type.PRODUCT_IMAGE,
status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="conv-test-1",
request_payload={"prompt": "", "batch_id": "bx", "ratio": "1:1"},
)
r = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/")
self.assertEqual(r.status_code, 200, r.content)
data = r.json()["tasks"]
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["prompt"], "")
self.assertEqual(data[0]["batch_id"], "bx")
def test_generate_with_refs_persists_and_tasks_endpoint_returns_them(self):
"""HTTP 全链路:带 reference_image_ids 提交 → 任务 payload 落 ids → tasks 接口把参考图解析回 {name,url}。
守护「上传的参考图被带进生成 + 切换/刷新后批次头仍能回显参考图」。"""
CreditAccount.objects.create(team=self.team, balance="100.0000")
patch("apps.ai.tasks.generate_standalone_image_task.delay").start()
self.addCleanup(patch.stopall)
ref = Asset.objects.create(
team=self.team, created_by=self.user, name="背心参考", asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD, category=Asset.Category.UPLOAD,
)
AssetFile.objects.create(asset=ref, object_key="r.png", bucket="b", content_type="image/png", preview_url="http://x/ref.png", is_primary=True)
r = self.client.post(
"/api/ai/generate-image/",
{"prompt": "按这件背心生成不同场景", "mode": "image", "count": 1, "reference_image_ids": [str(ref.id)]},
format="json",
)
self.assertEqual(r.status_code, 202, r.content)
conv_id = r.json()["conversation_id"]
# 任务 payload 落了参考图 id
task = AITask.objects.filter(conversation_id=conv_id).first()
self.assertEqual(task.request_payload.get("reference_image_ids"), [str(ref.id)])
# tasks 接口把参考图解析成 {name,url} 回显
data = self.client.get(f"/api/ai/image-conversations/{conv_id}/tasks/").json()["tasks"]
self.assertEqual(data[0]["reference_images"], [{"name": "背心参考", "url": "http://x/ref.png"}])
class StandaloneCategoryTests(TestCase):
"""图片趴三类归类(期2):模特上身图→model_tryon / 平台套图→platform_kit / 自由创作→free_create;