生图模型可选(火山/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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user