feat: AI生图功能丰富

This commit is contained in:
2026-09-27 22:41:33 +08:00
parent 1c4fe1a836
commit 7f1503294f
20 changed files with 627 additions and 336 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits` 且 images 必须是 **`[{image_url: dataURI}]` 对象数组**(纯字符串报错);均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 是枚举(1024x1536/1024x1024),不接受自定义尺寸。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits`;对于 `gpt-image-2`,`images` 必须传 **`[{"image_url": "data:image/...;base64,..."}]` 对象数组**。Pcore 通用文档也描述字符串数组,但该模型通道会异步校验 `images[].image_url`,以实际错误为准。均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 需使用模型支持的尺寸或比例。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。
## 4. AI 规划张数策略(`planner.py`)
@@ -301,7 +301,7 @@ const App: React.FC = () => {
setReporting(true);
try {
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
// 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。
// 超过 100 张时,background 会自动分批写入同一个商品,用户无需手动拆分。
const imagesToReport = selectedKeys.size > 0
? allImages.filter(img => selectedKeys.has(img.key))
: allImages;
+52 -2
View File
@@ -5,6 +5,8 @@
import type { TextMaterial } from '../collector/merge';
export interface ReportProductPayload {
/** 后续批次写入首次上报创建的商品;调用方首次上报无需提供。 */
product_id?: string;
source: {
platform: string;
itemId: string | null;
@@ -22,16 +24,24 @@ export interface ReportProductPayload {
}>;
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
refererOrigin?: string;
/** 多批上报时,仅最后一批启动素材下载,避免同一商品出现并发重复下载。 */
process_assets?: boolean;
}
export interface ReportProductResult {
product_id: string;
stage?: string;
assets_queued?: number;
assets_skipped?: number;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS) */
export async function reportProduct(
/**
* 后端单次最多接收 100 个素材;插件在此自动拆批,用户无需手动删减或分批上报。
* 所有批次共用首次返回的 product_id,且只在最后一批启动下载队列。
*/
const MATERIALS_BATCH_SIZE = 100;
async function postMaterials(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
@@ -44,3 +54,43 @@ export async function reportProduct(
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
return data as ReportProductResult;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS)。 */
export async function reportProduct(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
const batches = payload.images.length > 0
? Array.from({ length: Math.ceil(payload.images.length / MATERIALS_BATCH_SIZE) }, (_, index) =>
payload.images.slice(index * MATERIALS_BATCH_SIZE, (index + 1) * MATERIALS_BATCH_SIZE),
)
: [[]];
let productId = payload.product_id;
let lastResult: ReportProductResult | null = null;
let assetsQueued = 0;
let assetsSkipped = 0;
for (let index = 0; index < batches.length; index += 1) {
try {
const result = await postMaterials(reportBaseUrl, {
...payload,
product_id: productId,
// 文本仅需在首批写入;后续批次只追加素材,避免覆盖人工编辑的信息。
texts: index === 0 ? payload.texts : [],
images: batches[index],
process_assets: index === batches.length - 1,
});
productId = result.product_id;
lastResult = result;
assetsQueued += result.assets_queued ?? 0;
assetsSkipped += result.assets_skipped ?? 0;
} catch (error) {
const prefix = batches.length > 1 ? `第 ${index + 1}/${batches.length} 批上报失败:` : '';
throw new Error(prefix + (error instanceof Error ? error.message : String(error)));
}
}
if (!lastResult) throw new Error('上报未返回商品信息');
return { ...lastResult, assets_queued: assetsQueued, assets_skipped: assetsSkipped };
}
@@ -7,20 +7,64 @@ import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
const IMAGE_EXTENSION_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)$/i;
const IMAGE_EXTENSION_IN_PATH_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)\//i;
function normalizeEscapedText(raw: string): string {
return raw
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
// 1688/淘宝详情接口常把 HTML 放在 JSON/JS 字符串中。若不还原 \",DOMParser
// 会把它编码为 %22,并可能把后面的真实 https URL 误解析成当前 CDN 的路径。
.replace(/\\(["'])/g, '$1')
.replace(/\\\//g, '/');
}
function trimImageCandidate(value: string): string {
return value
.trim()
.replace(/[),;]+$/, '')
// “...jpg/” 是详情字符串错误拼接的常见形态,不是有效图片路径。
.replace(/(\.(?:avif|bmp|gif|jpe?g|png|webp))\/+$/i, '$1');
}
/**
* 修复被外层 CDN 路径/转义引号包裹的图片地址,例如:
* https://itemcdn.tmall.com/%22https://cbu01.alicdn.com/img/...jpg/
* 仅在检测到两个 URL 且内层是标准图片路径时取内层,正常带签名参数的 URL 保持原样。
*/
function recoverEmbeddedImageUrl(value: string): string | null {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// 非法百分号编码时仍按原文解析。
}
const matches = decoded.match(/https?:\/\/[^\s'"<>\\]+/gi) ?? [];
if (matches.length < 2) return null;
for (const match of matches.reverse()) {
const candidate = trimImageCandidate(match);
try {
const url = new URL(candidate);
if (/^https?:$/i.test(url.protocol) && IMAGE_EXTENSION_RE.test(url.pathname)) return url.href;
} catch {
// 继续检查更外层的候选。
}
}
return null;
}
function normalizeUrl(raw: string, baseUrl: string): string | null {
const value = raw.trim().replace(/^['"]|['"]$/g, '');
const cleaned = raw.trim().replace(/^['"]|['"]$/g, '');
const value = recoverEmbeddedImageUrl(cleaned) ?? cleaned;
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
try {
const url = new URL(value, baseUrl);
return /^https?:$/i.test(url.protocol) ? url.href : null;
if (!/^https?:$/i.test(url.protocol)) return null;
// 不允许图片扩展名后仍跟路径段(如 .jpg/),该 URL 不会指向图片资源。
if (IMAGE_EXTENSION_IN_PATH_RE.test(url.pathname)) return null;
return url.href;
} catch {
return null;
}
@@ -53,7 +97,7 @@ export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
for (const candidate of candidates) {
const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl);
const url = normalizeUrl(trimImageCandidate(candidate), baseUrl);
if (!url) continue;
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
+2 -1
View File
@@ -202,7 +202,8 @@ async def create_materials(
await db.commit()
await db.refresh(product)
if queued:
# 多批上报的最后一批可能全被去重;仍要扫描此前批次留下的 pending 素材。
if req.process_assets:
background.add_task(process_product_assets, str(product.id))
return MaterialsResponse(
product_id=str(product.id),
+22 -4
View File
@@ -316,12 +316,15 @@ async def download_suite_zip(suite_id: str):
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
settings = get_settings()
provider_name = resolve_provider(req.model, None, settings.image_provider)
structured_edit = bool(req.main_image_url and req.main_image_url.strip())
if structured_edit and req.model not in {"gpt-image-2", "gpt-image-2@pcore"}:
raise HTTPException(status_code=400, detail="单张 AI 编辑仅支持 Pcore gpt-image-2")
provider_name = "pcore" if structured_edit else resolve_provider(req.model, None, settings.image_provider)
if provider_name not in GENERATORS:
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
validate_model(provider_name, req.model)
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
spec = {"lang": req.target_language if structured_edit else "ru", "ratio": req.ratio if structured_edit else "3:4"}
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
product_id = (req.product_id or "").strip() or None
if product_id:
@@ -331,6 +334,11 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
if structured_edit:
ref_urls = list(dict.fromkeys(url.strip() for url in [req.main_image_url, *req.reference_image_urls] if url and url.strip()))
if len(ref_urls) > 3:
raise HTTPException(status_code=400, detail="辅助参考图最多两张")
else:
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
if not ref_urls:
raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
@@ -341,6 +349,10 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
if req.generate_size_chart:
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
if structured_edit and req.ratio != "3:4":
raise HTTPException(status_code=400, detail="仅扩图只支持 3:4")
if structured_edit and req.mode == "edit" and req.background == "reference" and len(ref_urls) < 2:
raise HTTPException(status_code=400, detail="参考图背景需要选择一张辅助参考图")
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
is_size_chart = bool(req.generate_size_chart)
@@ -354,10 +366,16 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
lang=spec["lang"],
ratio=spec["ratio"],
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
style_set=0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5,
style_set=req.style_set if structured_edit and req.mode == "edit" else (0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5),
request_payload={
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
"style_prompt": None,
"style_prompt": req.style_prompt if structured_edit else None,
"single_edit": {
"translate_text": req.translate_text,
"target_language": req.target_language,
"background": req.background,
"background_description": req.background_description,
} if structured_edit and req.mode == "edit" else None,
"requirements": req.prompt,
"operation": req.mode,
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
+1
View File
@@ -33,6 +33,7 @@ class MaterialsRequest(BaseModel):
texts: list[TextMaterial] = Field(default_factory=list)
images: list[ImageMaterial] = Field(default_factory=list)
refererOrigin: str | None = None # 下载源图时需带的 Referer
process_assets: bool = True # 插件多批上报时,仅最后一批触发下载队列
class MaterialsResponse(BaseModel):
+11 -2
View File
@@ -82,7 +82,7 @@ class ProductDimensions(BaseModel):
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
unit: Literal["mm", "cm"] = Field(default="mm", description="尺寸单位")
unit: Literal["mm", "cm"] = Field(default="cm", description="尺寸单位")
class PlanItem(BaseModel):
@@ -181,6 +181,8 @@ class SuiteOut(BaseModel):
class ImageEditSingleRequest(BaseModel):
"""POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。"""
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
main_image_url: str | None = Field(default=None, description="本次编辑的主图;新界面必须显式传入")
reference_image_urls: list[str] = Field(default_factory=list, max_length=2, description="最多两张辅助参考图,按选择顺序传给模型")
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
@@ -188,7 +190,14 @@ class ImageEditSingleRequest(BaseModel):
default="edit",
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
)
model: str = Field(default="nano-banana-2")
model: str = Field(default="gpt-image-2@pcore")
ratio: Literal["3:4", "1:1", "4:5"] = Field(default="3:4", description="输出画幅")
target_language: Literal["ru", "ar", "en"] = Field(default="ru", description="图内文字的目标语言")
translate_text: bool = Field(default=False, description="在本次生图调用中识别、翻译并重新排版图中文字")
style_set: int = Field(default=2, ge=1, le=5)
style_prompt: str | None = Field(default=None, description="用户修改后的风格提示词")
background: Literal["auto", "white", "solid", "scene", "reference"] = Field(default="auto")
background_description: str | None = Field(default=None, description="纯色或场景背景的具体要求")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field(
default=None,
+9 -5
View File
@@ -81,19 +81,21 @@ DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 +
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
if provider == "doubao":
return "1536x2048" if ratio == "3:4" else "2048x2048"
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
if provider == "rightapi":
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
if provider == "pcore":
# 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
return "1152x1536" if ratio == "3:4" else "1024x1024"
return {"3:4": "1152x1536", "4:5": "1280x1600"}.get(ratio, "1024x1024")
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
return "1152*1536" if ratio == "3:4" else "1440*1440"
return {"3:4": "1152*1536", "4:5": "1280*1600"}.get(ratio, "1440*1440")
if ratio == "3:4":
return "1536*2048" if is_wan else "768*1024"
if ratio == "4:5":
return "1536*1920" if is_wan else "960*1200"
return "2048*2048" if is_wan else "1024*1024"
_DOUBAO_ANTI_AI = (
@@ -520,7 +522,8 @@ async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model
kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
# Pcore 的 gpt-image-2 通道实际要求 OpenAI 兼容对象数组。
# 虽然通用文档描述字符串数组也可用,该通道会在异步任务中校验 images[].image_url。
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
@@ -723,6 +726,7 @@ async def advance_generation(
kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
# 与 _pcore_request 保持同一经 gpt-image-2 实际验证的形状,避免 Worker 路径漂移。
body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images]
if edit_mask:
# Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。
+36 -1
View File
@@ -6,6 +6,8 @@ import logging
import socket
import uuid
import httpx
from config import get_settings
from db import get_session_factory
from models import ProductAsset
@@ -23,6 +25,7 @@ from services.jobs import (
)
from services.prompts import build_context, build_prompt
from services.prompts.common import requirements_block
from services.prompts.single_edit import build_single_edit_prompt
from services.storage import get_storage
from services.suite_service import append_generated_asset
from services.tasks import TaskImage
@@ -31,6 +34,10 @@ from services.video_renderer import VideoRenderCancelled, render_slideshow
log = logging.getLogger("background.worker")
# 异步供应商任务已经持久化了 task id 后,轮询网络失败不能把任务判为失败。
# 下一次轮询复用同一 task id,不会再次提交/计费。
PROVIDER_POLL_RETRY_DELAY_SECONDS = 30
def _worker_owner() -> str:
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
@@ -191,7 +198,11 @@ async def process_item(claim: ClaimedItem) -> None:
"pcore": "gpt-image-2",
}.get(provider, settings.ark_image_model)
try:
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item)
single_edit = payload.get("single_edit")
refs = (
[image["url"] for image in payload.get("ref_images") or []]
if single_edit else _refs_for_job(list(payload.get("ref_images") or []), job_item)
)
operation = payload.get("operation", "edit")
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
edit_mask: str | None = None
@@ -207,6 +218,15 @@ async def process_item(claim: ClaimedItem) -> None:
edit_mask = outpaint.mask_data_url
else:
context = build_context(payload.get("context") or {}, fallback_name="")
if single_edit:
prompt = build_single_edit_prompt(
requirements=payload.get("requirements") or "", config=single_edit,
context=context, style_set=claim.style_set,
style_prompt=payload.get("style_prompt"), ratio=claim.ratio,
is_size_chart=job_item.get("kind") == "size_chart",
reference_count=len(refs) - 1,
)
else:
prompt_extra = job_item.get("prompt_extra") or job_item
prompt = build_prompt(
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
@@ -274,6 +294,21 @@ async def process_item(claim: ClaimedItem) -> None:
claim.item_id, result_url=result_url, source_url=source_url or None,
watermark_config=watermark_config, generated_asset_id=asset_id,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if claim.provider_task_id and claim.provider_task_kind:
log.warning(
"供应商轮询网络暂时失败,%ds 后重试同一任务 %s:%s",
PROVIDER_POLL_RETRY_DELAY_SECONDS, claim.provider_task_id, exc.__class__.__name__,
)
await mark_waiting_provider(
claim.item_id,
claim.provider_task_id,
claim.provider_task_kind,
delay_seconds=PROVIDER_POLL_RETRY_DELAY_SECONDS,
)
return
log.exception("后台图片任务 %s/%s 网络请求失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc) or exc.__class__.__name__)
except Exception as exc: # noqa: BLE001
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc))
+1 -1
View File
@@ -140,7 +140,7 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
(label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height"))
if dimensions.get(key) is not None
]
unit = (dimensions.get("unit") or "mm") if isinstance(dimensions, dict) else "mm"
unit = (dimensions.get("unit") or "cm") if isinstance(dimensions, dict) else "cm"
dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions)
known_labels = [label for label, _ in known_dimensions]
missing_labels = [label for label in ("length", "width", "height") if label not in known_labels]
+68
View File
@@ -0,0 +1,68 @@
"""单张 AI 编辑提示词:一次图像模型调用完成编辑、翻译和排版。"""
from __future__ import annotations
from services.prompts.common import requirements_block, resolve_style
LANGUAGES = {"ru": "Russian", "ar": "Arabic", "en": "English"}
BACKGROUNDS = {
"auto": "Keep the original background unless the user's requirements ask to change it.",
"white": "Replace only the background with clean pure white; retain natural contact shadows.",
"solid": "Replace only the background with a clean solid color as described below.",
"scene": "Replace only the background with the described realistic scene. Keep the product as the hero.",
"reference": "Use Image 2 for background and lighting direction. Do not copy its product.",
}
def build_single_edit_prompt(
*, requirements: str, config: dict, context: dict, style_set: int,
style_prompt: str | None, ratio: str, is_size_chart: bool, reference_count: int,
) -> str:
"""Image 1 always supplies the product; following images are supporting references only."""
language = LANGUAGES.get(config.get("target_language"), "Russian")
background = config.get("background") or "auto"
sections = [
requirements_block(requirements),
"Create exactly ONE finished e-commerce image. Image 1 is the fixed main product image. "
"Preserve the product's shape, proportions, color, material, logo and distinguishing details. "
"Do not replace it with products from any other image.",
]
if reference_count:
sections.append(
f"Images 2 through {reference_count + 1} are auxiliary references only. "
"Use them for product detail, material, scene or lighting as appropriate; "
"Image 1 remains the source of truth for the product."
)
sections.append("BACKGROUND: " + BACKGROUNDS.get(background, BACKGROUNDS["auto"]))
if config.get("background_description"):
sections.append("Background details: " + str(config["background_description"]).strip())
if config.get("translate_text"):
sections.append(
f"In this same image-generation pass, read the visible marketing text in Image 1, "
f"translate its meaning into {language}, replace the source-language marketing copy, "
"and redesign the text layout for a polished, legible composition. "
"For Arabic, use correct right-to-left typesetting. Keep factual values and units exact. "
"Preserve text printed on the physical product and its branding unless the user explicitly asks to edit them. "
"Do not add unsupported claims."
)
else:
sections.append(
"Do not invent marketing copy or translate existing image text. "
"Preserve text printed on the physical product unless explicitly requested otherwise."
)
if is_size_chart:
annotation = context.get("dimension_annotation_instruction")
if annotation:
sections.append(
"Create a clean product measurement diagram with legible dimension arrows. "
+ str(annotation)
)
style = resolve_style(style_set, style_prompt)
sections.append("VISUAL STYLE: " + str(style["tone"]))
sections.append(
f"OUTPUT: exactly {ratio} portrait or square composition as specified. "
"Apply the user's explicit background and text instructions before style preferences. "
"Do not add a watermark or extra panels."
)
return "\n\n".join(part for part in sections if part)
+42
View File
@@ -7,6 +7,8 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
@@ -58,6 +60,46 @@ class WorkerCancellationTests(unittest.IsolatedAsyncioTestCase):
cancelled.assert_awaited_once_with(claim.item_id)
waiting.assert_not_awaited()
async def test_provider_poll_timeout_keeps_existing_task_for_retry(self) -> None:
claim = ClaimedItem(
job_id="00000000-0000-0000-0000-000000000001",
item_id="00000000-0000-0000-0000-000000000002",
job_kind="image_edit",
product_id=None,
provider="pcore",
model="gpt-image-2",
platform="ozon",
lang="ru",
ratio="3:4",
style_set=5,
request_payload={"context": {"title": "test"}, "ref_images": []},
item_payload={"kind": "custom", "title": "AI生图"},
provider_task_id="provider-task",
provider_task_kind="edits",
)
settings = SimpleNamespace(
image_provider="pcore",
dashscope_model="wan2.7-image-pro",
rightapi_image_model="gpt-image-2",
ark_image_model="doubao-seedream-4-5-251128",
)
with (
patch("services.job_worker.get_settings", return_value=settings),
patch("services.job_worker.build_context", return_value={}),
patch("services.job_worker.build_prompt", return_value="prompt"),
patch("services.job_worker._refs_for_job", return_value=[]),
patch("services.job_worker.advance_generation", new=AsyncMock(side_effect=httpx.ConnectTimeout("timeout"))),
patch("services.job_worker.mark_waiting_provider", new=AsyncMock()) as waiting,
patch("services.job_worker.mark_item_failed", new=AsyncMock()) as failed,
):
await process_item(claim)
waiting.assert_awaited_once_with(
claim.item_id, "provider-task", "edits", delay_seconds=30,
)
failed.assert_not_awaited()
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
"""单张编辑的新参数必须进入同一次生图任务,且主图顺序与尺寸准确。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.suite import suite_image_edit, _with_dimensions
from schemas.suite import ImageEditSingleRequest, ProductDimensions
from services.generator import _image_size
from services.prompts.common import build_context
from services.prompts.single_edit import build_single_edit_prompt
class SingleEditRequestTests(unittest.IsolatedAsyncioTestCase):
async def test_main_and_two_auxiliary_images_keep_order_in_one_item(self) -> None:
request = ImageEditSingleRequest(
main_image_url="main", reference_image_urls=["detail", "scene"],
prompt="替换背景", model="gpt-image-2@pcore", translate_text=True,
target_language="ar", background="reference", ratio="4:5",
)
job = AsyncMock(return_value=SimpleNamespace(id="task-id"))
with patch("api.suite.create_image_job", job):
response = await suite_image_edit(request)
self.assertEqual(response.suite_id, "task-id")
payload = job.await_args.kwargs
self.assertEqual(payload["ratio"], "4:5")
self.assertEqual(payload["lang"], "ar")
self.assertEqual(payload["provider"], "pcore")
self.assertEqual(payload["model"], "gpt-image-2")
self.assertEqual(len(payload["item_payloads"]), 1)
self.assertEqual([image["url"] for image in payload["request_payload"]["ref_images"]], ["main", "detail", "scene"])
self.assertTrue(payload["request_payload"]["single_edit"]["translate_text"])
async def test_structured_edit_rejects_other_provider(self) -> None:
with self.assertRaises(HTTPException) as raised:
await suite_image_edit(ImageEditSingleRequest(
main_image_url="main", model="nano-banana-2",
))
self.assertEqual(raised.exception.status_code, 400)
class SingleEditPromptTests(unittest.TestCase):
def test_translation_background_and_partial_dimensions_share_one_prompt(self) -> None:
context = build_context(_with_dimensions(
{"title": "AI 精修"}, ProductDimensions(width=8, unit="cm"), required=True,
))
prompt = build_single_edit_prompt(
requirements="保留商品颜色", config={"translate_text": True, "target_language": "ar", "background": "scene", "background_description": "warm kitchen"},
context=context, style_set=2, style_prompt=None, ratio="3:4", is_size_chart=True, reference_count=2,
)
self.assertIn("Image 1 is the fixed main product image", prompt)
self.assertIn("Images 2 through 3 are auxiliary", prompt)
self.assertIn("Arabic", prompt)
self.assertIn("right-to-left", prompt)
self.assertIn("warm kitchen", prompt)
self.assertIn("width: 8.0 cm", prompt)
self.assertIn("Do not draw, label, or show an arrow for: length, height", prompt)
def test_four_to_five_size_does_not_fall_back_to_square(self) -> None:
for provider, is_wan, model in (
("pcore", True, "gpt-image-2"),
("rightapi", True, "gpt-image-2"),
("doubao", True, "seedream"),
("tongyi", True, "wan2.6-image"),
("tongyi", False, "qwen-image-3.0-pro"),
):
with self.subTest(provider=provider, model=model):
width, height = map(int, _image_size(provider, "4:5", is_wan, model).replace("*", "x").split("x"))
self.assertEqual(width * 5, height * 4)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -22,8 +22,10 @@ project_pnpm() {
BACKEND_PID=""
STUDIO_PID=""
WORKER_SUPERVISOR_PID=""
WORKER_SOURCE_WATCH_PID=""
COLLECTOR_WATCH_PID=""
COLLECTOR_STAMP=""
WORKER_SOURCE_STAMP=""
CLEANUP_DONE=false
stop_process_tree() {
@@ -44,8 +46,10 @@ cleanup() {
stop_process_tree "$BACKEND_PID"
stop_process_tree "$STUDIO_PID"
stop_process_tree "$WORKER_SUPERVISOR_PID"
stop_process_tree "$WORKER_SOURCE_WATCH_PID"
stop_process_tree "$COLLECTOR_WATCH_PID"
[[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null
[[ -n "$WORKER_SOURCE_STAMP" ]] && rm -f "$WORKER_SOURCE_STAMP" 2>/dev/null
if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then
rm -f "$INSTANCE_PID_FILE"
fi
@@ -189,6 +193,24 @@ supervise_worker &
WORKER_SUPERVISOR_PID=$!
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
# Uvicorn 的热更新只管理 Web 进程;Worker 独立运行,代码更新后必须主动重启,
# 否则它会继续用内存中已导入的旧 generator.py 提交模型任务。
WORKER_SOURCE_STAMP="$(mktemp /tmp/osk-worker-source.XXXXXX)"
watch_worker_source() {
while true; do
sleep 2
if [[ -n "$(find server -type f \( -name '*.py' -o -name '*.yaml' \) -newer "$WORKER_SOURCE_STAMP" -print -quit 2>/dev/null)" ]]; then
touch "$WORKER_SOURCE_STAMP"
echo "🔧 检测到 Worker 源码改动,正在重启 Worker 以应用新版本…"
for worker_pid in $(pgrep -P "$WORKER_SUPERVISOR_PID" 2>/dev/null); do
kill -TERM "$worker_pid" 2>/dev/null || true
done
fi
done
}
watch_worker_source &
WORKER_SOURCE_WATCH_PID=$!
if ! command -v corepack >/dev/null 2>&1; then
echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)"
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
+15 -2
View File
@@ -22,7 +22,6 @@ interface Props {
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
const [models, setModels] = useState<AiModelOption[]>([]);
const [model, setModel] = useState<string>('');
const [sourceText, setSourceText] = useState('');
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<CopyResponse | null>(null);
@@ -41,8 +40,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
return parts.join('\n\n');
}, [raw]);
// 文案模块的“采集信息”是可人工整理的 AI 输入。优先恢复用户上次保存的内容;
// 只有从未编辑过时才从当前采集数据自动拼出默认值。
const savedSourceText = typeof raw.copy_source_text === 'string' ? raw.copy_source_text : null;
const [sourceText, setSourceText] = useState(() => savedSourceText ?? defaultSource);
useEffect(() => {
if (!sourceText && defaultSource) setSourceText(defaultSource);
getAiModels()
.then((r) => {
setModels(r.models);
@@ -52,6 +55,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** 与价格试算一致:用户完成输入(失焦)后将编辑后的 AI 输入落到当前商品。 */
const saveSourceText = async () => {
if (sourceText === savedSourceText) return;
await onSave({ raw: { ...raw, copy_source_text: sourceText } });
};
const onGenerate = async () => {
if (sourceText.trim().length < 10) {
message.warning('请先粘贴至少 10 字的商品资料');
@@ -115,8 +124,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
rows={16}
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
onBlur={() => { void saveSourceText(); }}
placeholder="采集的商品资料(可编辑后生成)"
/>
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 12 }}>
修改后失去焦点会自动保存
</Text>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Select
style={{ width: 180 }}
@@ -0,0 +1,40 @@
.single-ai-drawer .ant-drawer-body { padding: 18px 22px; background: #f8f9fb; }
.single-ai-drawer .ant-drawer-footer { padding: 12px 22px; }
.single-ai-title { display: flex; gap: 12px; align-items: baseline; font-weight: 650; }
.single-ai-title .ant-typography { font-size: 12px; font-weight: normal; }
.single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(310px, .82fr); gap: 18px; align-items: start; }
.single-ai-media, .single-ai-settings { display: grid; gap: 14px; min-width: 0; }
.single-ai-section { background: #fff; border: 1px solid #e9ebef; border-radius: 12px; padding: 16px; min-width: 0; }
.single-ai-section-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; }
.single-ai-section-head strong { font-size: 14px; }
.single-ai-section-head .ant-typography { font-size: 12px; }
.single-ai-reference-grid { display: grid; grid-template-columns: 2fr 1fr; grid-template-rows: repeat(2, 1fr); aspect-ratio: 3 / 2; gap: 8px; min-height: 220px; }
.single-ai-reference-grid.outpaint { grid-template-columns: 1fr; }
.single-ai-main-image, .single-ai-aux-image { position: relative; min-width: 0; min-height: 0; overflow: hidden; border-radius: 9px; background: #f3f4f6; }
.single-ai-main-image { grid-row: span 2; }
.single-ai-main-image img, .single-ai-aux-image img { width: 100%; height: 100%; display: block; object-fit: contain; }
.single-ai-main-image > span, .single-ai-aux-image > span { position: absolute; left: 8px; bottom: 8px; border-radius: 5px; padding: 3px 7px; background: rgba(20, 24, 33, .7); color: #fff; font-size: 11px; }
.single-ai-aux-image > .ant-btn { position: absolute; top: 6px; right: 6px; }
.single-ai-aux-image > button:not(.ant-btn) { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px dashed #c9ccd3; background: #fafafa; cursor: pointer; color: #737984; }
.single-ai-aux-image > button:not(.ant-btn) span { position: static; background: none; color: inherit; }
.single-ai-aux-image > .single-ai-change-ref { position: absolute !important; right: 6px; bottom: 6px; width: auto !important; height: auto !important; padding: 3px 7px; border: 0 !important; border-radius: 5px; background: rgba(20, 24, 33, .7) !important; color: white !important; font-size: 11px; }
.single-ai-result-frame { width: min(100%, 300px); max-height: 440px; margin: 0 auto 10px; overflow: hidden; background: #f7f8fa; border: 1px dashed #d9dde4; border-radius: 9px; display: flex; justify-content: center; align-items: center; }
.single-ai-result-frame .ant-image { display: block; width: 100%; height: 100%; }
.single-ai-result-frame .ant-image img { display: block; width: 100%; height: 100%; object-fit: contain; }
.single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px; text-align: center; color: #808793; font-size: 12px; }
.single-ai-empty strong { color: #30343b; font-size: 14px; }
.single-ai-sparkle { color: #25ab65; font-size: 32px; }
.single-ai-settings .ant-typography-secondary { display: block; margin-top: 6px; font-size: 12px; }
.single-ai-style-grid { display: flex; flex-wrap: wrap; gap: 7px; }
.single-ai-style-grid button { border: 1px solid #dce0e6; background: #fff; border-radius: 18px; padding: 5px 12px; cursor: pointer; }
.single-ai-style-grid button.selected { border-color: #20a260; color: #16874f; background: #effaf3; }
.single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 74px; gap: 7px; margin-bottom: 8px; }
.single-ai-dimensions .ant-input-number { width: 100%; }
.single-ai-advanced { display: grid; gap: 8px; }
.single-ai-picker { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; max-height: 420px; overflow: auto; }
.single-ai-picker button { min-width: 0; padding: 4px; border: 1px solid #ddd; background: #fff; border-radius: 8px; cursor: pointer; }
.single-ai-picker img { display: block; width: 100%; aspect-ratio: 1; object-fit: contain; }
.single-ai-picker span { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.single-ai-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.single-ai-footer > div { display: flex; gap: 8px; }
@media (max-width: 760px) { .single-ai-layout { grid-template-columns: 1fr; } .single-ai-footer > .ant-typography { display: none; } .single-ai-footer { justify-content: flex-end; } }
+149 -296
View File
@@ -1,46 +1,61 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Radio, Select, Space, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Checkbox, Drawer, Image, Input, InputNumber, message, Modal, Radio, Select, Spin, Switch, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite';
import {
DEFAULT_IMAGE_MODEL, STYLE_SET_OPTIONS, cancelSuite, getSuite,
imageEditSingle, ProductDimensions, WatermarkPayload,
} from '@/services/suite';
import './AiImageGenModal.css';
const { Text } = Typography;
type ImageOption = { url: string; name: string };
type Ratio = '3:4' | '1:1' | '4:5';
type Language = 'ru' | 'ar' | 'en';
type Background = 'auto' | 'white' | 'solid' | 'scene' | 'reference';
interface Props {
open: boolean;
productId: string;
/** 待生成的源图(采集图或生成图) */
source: { url: string; name: string; afterAssetId?: string } | null;
/** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */
referenceOptions: Array<{ url: string; name: string }>;
source: (ImageOption & { afterAssetId?: string }) | null;
referenceOptions: ImageOption[];
onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void;
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
onSubmitted?: (suiteId: string) => void;
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
watermark?: WatermarkPayload;
}
/**
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) {
const [auxiliary, setAuxiliary] = useState<(ImageOption | null)[]>([null, null]);
const [choosingSlot, setChoosingSlot] = useState<number | null>(null);
const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
const [generateSizeChart, setGenerateSizeChart] = useState(false);
const [ratio, setRatio] = useState<Ratio>('3:4');
const [translateText, setTranslateText] = useState(false);
const [language, setLanguage] = useState<Language>('ru');
const [background, setBackground] = useState<Background>('auto');
const [backgroundDescription, setBackgroundDescription] = useState('');
const [styleSet, setStyleSet] = useState<number>(2);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [showStylePrompt, setShowStylePrompt] = useState(false);
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
const [sizeChartOverride, setSizeChartOverride] = useState<boolean | null>(null);
const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState('');
const [resultRatio, setResultRatio] = useState<Ratio>('3:4');
const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState('');
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState<Set<string>>(() => new Set());
const [advancedOpen, setAdvancedOpen] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const activeTaskRef = useRef<string | null>(null);
const previousSourceRef = useRef<string | null>(null);
const hasDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
const generateSizeChart = hasDimensions && (sizeChartOverride ?? true);
const stylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((item) => item.value === styleSet)?.prompt ?? '';
const stopPolling = () => {
pollRunRef.current += 1;
@@ -50,21 +65,25 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
pollAbortRef.current = null;
};
// 每次打开重置(保留模型选择,方便连续精修);后台任务运行时不重置,以免丢失跟踪。
useEffect(() => {
if (open && !activeTaskRef.current) {
if (!open || !source || activeTaskRef.current || previousSourceRef.current === source.url) return;
previousSourceRef.current = source.url;
setAuxiliary([null, null]);
setPrompt('');
setMode('edit');
setDimensions({ unit: 'mm' });
setGenerateSizeChart(false);
setRatio('3:4');
setTranslateText(false);
setLanguage('ru');
setBackground('auto');
setBackgroundDescription('');
setDimensions({ unit: 'cm' });
setSizeChartOverride(null);
setResultUrl('');
setGenerating(false);
setTaskId('');
setResultRatio('3:4');
setTaskError('');
setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set());
}
setTaskId('');
}, [open, source?.url]);
useEffect(() => { if (!open) previousSourceRef.current = null; }, [open]);
useEffect(() => () => stopPolling(), []);
const pollTask = (suiteId: string) => {
@@ -73,12 +92,8 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
const controller = new AbortController();
pollAbortRef.current = controller;
activeTaskRef.current = suiteId;
const startedAt = Date.now();
let failures = 0;
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const schedule = (delay: number) => { pollTimerRef.current = setTimeout(() => void poll(), delay); };
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
@@ -100,34 +115,24 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
if (result) {
setResultUrl(result.url);
setTaskError(suite.error || '');
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(result.url);
} else if (suite.status === 'cancelled') {
setTaskError('任务已取消');
message.info('任务已取消');
message.success('单张图片生成完成');
} else {
const detail = suite.error || '生成失败,未返回图片';
const detail = suite.status === 'cancelled' ? '任务已取消' : suite.error || '生成失败,未返回图片';
setTaskError(detail);
message.error(detail);
if (suite.status !== 'cancelled') message.error(detail);
}
return;
}
// 单张任务最长可达 5 分钟;超时只提示,继续跟踪,避免后台完成但前端丢结果。
if (Date.now() - startedAt > 6 * 60_000) {
setTaskError('任务耗时较长,仍在后台生成;可关闭弹窗,结果完成后会自动回写。');
}
schedule(3000);
} catch (e) {
} catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
failures += 1;
if (failures >= 3) {
finish();
const detail = '连续查询任务进度失败;后台任务可能仍在执行,稍后可在「生成结果」查看。';
setTaskError(detail);
message.error(detail);
setTaskError('查询进度失败,任务仍可能在后台运行;可在生成结果区域查看。');
return;
}
// 失败后指数退避:3 秒、6 秒、12 秒;下一次只在本次请求完成后才会发出。
schedule(Math.min(3000 * (2 ** failures), 12_000));
}
};
@@ -135,50 +140,37 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
};
const onGenerate = async () => {
if (!source) return;
if (selectedReferenceUrls.size === 0) {
message.warning('请至少选择一张参考底图');
return;
}
if (mode === 'outpaint' && selectedReferenceUrls.size !== 1) {
message.warning('仅扩图只能选择一张参考底图');
return;
}
const hasAnyDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
if (generateSizeChart && !hasAnyDimensions) {
message.warning('生成尺寸标注图前,请至少填写一项商品尺寸');
if (!source?.url || generating) return;
if (mode === 'edit' && background === 'reference' && !auxiliary.some(Boolean)) {
message.warning('请选择一张辅助参考图作为背景参考');
return;
}
setGenerating(true);
setResultUrl('');
setResultRatio(mode === 'outpaint' ? '3:4' : ratio);
setTaskError('');
try {
const r = await imageEditSingle({
const response = await imageEditSingle({
product_id: productId,
image_urls: Array.from(selectedReferenceUrls),
prompt: prompt.trim(),
mode,
model,
append: true,
after_asset_id: source.afterAssetId,
main_image_url: source.url,
reference_image_urls: mode === 'outpaint' ? [] : auxiliary.flatMap((item) => item ? [item.url] : []),
prompt: prompt.trim(), mode, model: DEFAULT_IMAGE_MODEL,
ratio: mode === 'outpaint' ? '3:4' : ratio, translate_text: mode === 'edit' && translateText,
target_language: language, style_set: styleSet, style_prompt: stylePrompt,
background, background_description: backgroundDescription.trim() || null,
generate_size_chart: mode === 'edit' && generateSizeChart,
dimensions: hasDimensions ? dimensions : null,
append: true, after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined,
generate_size_chart: generateSizeChart,
dimensions: hasAnyDimensions ? dimensions : null,
});
setTaskId(r.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
if (onSubmitted) {
onSubmitted(r.suite_id);
onClose();
} else {
// 独立使用该组件时仍保留自身轮询能力。
pollTask(r.suite_id);
}
} catch (e) {
message.error(apiErrorMessage(e));
setTaskError(apiErrorMessage(e));
setTaskId(response.suite_id);
onSubmitted?.(response.suite_id);
pollTask(response.suite_id);
} catch (error) {
const detail = apiErrorMessage(error);
setTaskError(detail);
setGenerating(false);
message.error(detail);
}
};
@@ -190,228 +182,89 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
activeTaskRef.current = null;
setGenerating(false);
setTaskId('');
setTaskError('任务已取消;已完成的图片会保留在生成结果中。');
} catch (e) {
message.error(apiErrorMessage(e));
}
setTaskError('任务已取消');
} catch (error) { message.error(apiErrorMessage(error)); }
};
const referenceImages = Array.from(
new Map(
[...referenceOptions, ...(source ? [{ url: source.url, name: source.name }] : [])]
.filter((image) => image.url)
.map((image) => [image.url, image]),
).values(),
);
const toggleReference = (url: string) => {
if (generating) return;
setSelectedReferenceUrls((previous) => {
if (mode === 'outpaint') return new Set([url]);
const next = new Set(previous);
if (next.has(url)) next.delete(url); else next.add(url);
return next;
});
const choices = Array.from(new Map(referenceOptions.filter((item) => item.url && item.url !== source?.url)
.map((item) => [item.url, item])).values());
const chooseReference = (item: ImageOption) => {
if (choosingSlot === null) return;
setAuxiliary((previous) => previous.map((selected, index) =>
index === choosingSlot ? item : selected?.url === item.url ? null : selected));
setChoosingSlot(null);
};
const changeMode = (nextMode: 'edit' | 'outpaint') => {
setMode(nextMode);
if (nextMode === 'outpaint') {
setGenerateSizeChart(false);
setModel('gpt-image-2@pcore');
setSelectedReferenceUrls((previous) => {
const first = previous.values().next().value as string | undefined;
return new Set(first ? [first] : (source?.url ? [source.url] : []));
});
}
};
return (
<>
{open && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
background: '#fff',
borderRadius: 10,
padding: 20,
width: 760,
maxWidth: '92vw',
maxHeight: '88vh',
overflow: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<Radio.Group
size="small"
value={mode}
buttonStyle="solid"
disabled={generating}
onChange={(e) => changeMode(e.target.value)}
>
<Radio.Button value="edit">AI 编辑</Radio.Button>
<Radio.Button value="outpaint">仅扩图至 3:4</Radio.Button>
</Radio.Group>
return <>
<Drawer className="single-ai-drawer" title={<div className="single-ai-title">单张 AI 编辑 <Text type="secondary">每次生成 1 张</Text></div>}
placement="right" width="min(1040px, 100vw)" open={open} onClose={onClose} destroyOnHidden={false}
footer={<div className="single-ai-footer"><Text type="secondary">生成结果会同时保存到「生成结果」</Text><div>
{generating && taskId && <Button danger onClick={() => void onCancelTask()}>取消任务</Button>}
<Button onClick={onClose}>关闭</Button>
<Button type="primary" loading={generating} disabled={!source} onClick={() => void onGenerate()}>{resultUrl ? '重新生成 1 张' : '生成 1 张'}</Button>
</div></div>}>
<div className="single-ai-layout">
<div className="single-ai-media">
<section className="single-ai-section">
<div className="single-ai-section-head"><strong>主图与辅助参考</strong><Text type="secondary">{mode === 'outpaint' ? '仅使用主图' : '主图固定 · 辅图最多 2 张'}</Text></div>
<div className={`single-ai-reference-grid${mode === 'outpaint' ? ' outpaint' : ''}`}>
<div className="single-ai-main-image"><img src={source?.url} alt={source?.name || '主图'} /><span>主图 · 编辑底图</span></div>
{mode === 'edit' && auxiliary.map((item, index) => <div className="single-ai-aux-image" key={index}>
{item ? <><img src={item.url} alt={item.name} /><span>辅图 {index + 1}</span><Button size="small" icon={<DeleteOutlined />} title="移除辅图" disabled={generating} onClick={() => setAuxiliary((previous) => previous.map((value, i) => i === index ? null : value))} /></>
: <button type="button" disabled={generating} onClick={() => setChoosingSlot(index)}><PlusOutlined /><span>添加辅图 {index + 1}</span></button>}
{item && <button className="single-ai-change-ref" type="button" disabled={generating} onClick={() => setChoosingSlot(index)}>更换</button>}
</div>)}
</div>
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
</section>
<section className="single-ai-section">
<div className="single-ai-section-head"><strong>AI 生成结果</strong><Text type="secondary">按 {resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio} 展示</Text></div>
<div className="single-ai-result-frame" style={{ aspectRatio: (resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio).replace(':', ' / ') }}>
{resultUrl ? <Image src={resultUrl} alt="AI 生成结果" /> : generating ? <div className="single-ai-empty"><Spin size="large" /><strong>正在生成图片</strong><span>完成后会直接展示在这里</span></div>
: <div className="single-ai-empty"><span className="single-ai-sparkle">✦</span><strong>生成的图片会展示在这里</strong><span>主图是编辑底图,生成结果单独显示</span></div>}
</div>
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 7 }}>
<Text strong style={{ fontSize: 13 }}>参考底图{mode === 'outpaint' ? '(仅限单张)' : '(可多选)'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>已选 {selectedReferenceUrls.size} 张</Text>
{resultUrl && <a href={resultUrl} target="_blank" rel="noreferrer">打开原图</a>}
{taskError && <Alert style={{ marginTop: 10 }} type="error" showIcon message={taskError} />}
</section>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 8, maxHeight: 190, overflowY: 'auto', padding: 2 }}>
{referenceImages.map((image) => {
const selected = selectedReferenceUrls.has(image.url);
return (
<button
key={image.url}
type="button"
title={`${selected ? '取消选择' : '选择'}:${image.name}`}
onClick={() => toggleReference(image.url)}
disabled={generating}
style={{ position: 'relative', padding: 2, border: `2px solid ${selected ? '#1677ff' : '#e5e7eb'}`, borderRadius: 7, background: selected ? '#e6f4ff' : '#fff', cursor: generating ? 'not-allowed' : 'pointer', aspectRatio: '1 / 1', overflow: 'hidden' }}
>
<img src={image.url} alt={image.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', borderRadius: 4 }} />
{selected && <span style={{ position: 'absolute', top: 3, right: 3, width: 18, height: 18, borderRadius: '50%', background: '#1677ff', color: '#fff', fontSize: 12, lineHeight: '18px' }}>✓</span>}
</button>
);
})}
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Image
src={source?.url}
alt="当前图片"
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{mode === 'edit' && <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: generateSizeChart ? 12 : 0 }}>
<Checkbox
checked={generateSizeChart}
onChange={(e) => setGenerateSizeChart(e.target.checked)}
disabled={generating}
>
生成尺寸标注图(未知方向留空)
</Checkbox>
<div className="single-ai-settings">
<section className="single-ai-section"><div className="single-ai-section-head"><strong>编辑要求</strong></div>
<Input.TextArea rows={4} value={prompt} disabled={generating} onChange={(event) => setPrompt(event.target.value)} placeholder={mode === 'outpaint' ? '描述新增边缘的画面;原图区域会保持不变' : '描述要改什么,例如:保留商品外观,换成温暖的客厅背景,突出材质质感'} />
</section>
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>图中文字翻译</strong><Switch checked={translateText} disabled={generating} onChange={setTranslateText} /></div>
{translateText && <><Select value={language} disabled={generating} onChange={setLanguage} options={[{ value: 'ru', label: '俄文(默认)' }, { value: 'ar', label: '阿拉伯语' }, { value: 'en', label: '英语' }]} style={{ width: '100%' }} />
<Text type="secondary">生成时识别并翻译图片文案,重新排版;不会额外发起识图请求。</Text></>}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>背景</strong></div>
<Select value={background} disabled={generating} onChange={setBackground} style={{ width: '100%' }} options={[
{ value: 'auto', label: '保持原背景 / 按描述' }, { value: 'white', label: '纯白背景' },
{ value: 'solid', label: '指定纯色' }, { value: 'scene', label: '更换场景' }, { value: 'reference', label: '参考首张辅图背景' },
]} />
{['solid', 'scene', 'reference'].includes(background) && <Input style={{ marginTop: 9 }} value={backgroundDescription} disabled={generating} onChange={(event) => setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>视觉风格</strong></div>
<div className="single-ai-style-grid">{STYLE_SET_OPTIONS.map((item) => <button key={item.value} type="button" className={styleSet === item.value ? 'selected' : ''} disabled={generating} onClick={() => setStyleSet(item.value)}>{item.label}</button>)}</div>
<Button type="link" size="small" style={{ paddingLeft: 0, marginTop: 6 }} onClick={() => setShowStylePrompt(!showStylePrompt)}>{showStylePrompt ? '收起风格提示词' : '查看 / 修改风格提示词'}</Button>
{showStylePrompt && <Input.TextArea rows={4} value={stylePrompt} disabled={generating} onChange={(event) => setStylePrompts((previous) => ({ ...previous, [styleSet]: event.target.value }))} />}
</section>}
{mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>商品尺寸</strong><Text type="secondary">默认 cm</Text></div>
<div className="single-ai-dimensions">{(['length', 'width', 'height'] as const).map((key, index) => <InputNumber key={key} min={0} precision={2} controls={false} disabled={generating} value={dimensions[key]} placeholder={['长', '宽', '高'][index]} onChange={(value) => setDimensions((previous) => ({ ...previous, [key]: typeof value === 'number' ? value : undefined }))} />)}
<Select value={dimensions.unit ?? 'cm'} disabled={generating} onChange={(unit: 'mm' | 'cm') => setDimensions((previous) => ({ ...previous, unit }))} options={[{ value: 'cm', label: 'cm' }, { value: 'mm', label: 'mm' }]} /></div>
{hasDimensions && <Checkbox checked={generateSizeChart} disabled={generating} onChange={(event) => setSizeChartOverride(event.target.checked)}>生成尺寸标注图</Checkbox>}
<Text type="secondary">填写尺寸后默认生成标注图;只标注已填写的方向。</Text>
</section>}
<section className="single-ai-section"><div className="single-ai-section-head"><strong>画面比例</strong></div>
<Radio.Group optionType="button" buttonStyle="solid" value={mode === 'outpaint' ? '3:4' : ratio} disabled={generating || mode === 'outpaint'} onChange={(event) => setRatio(event.target.value as Ratio)} options={['3:4', '1:1', '4:5']} />
<Button type="link" size="small" style={{ display: 'block', paddingLeft: 0, marginTop: 8 }} onClick={() => setAdvancedOpen(!advancedOpen)}>{advancedOpen ? '收起生成方式' : '生成方式'}</Button>
{advancedOpen && <div className="single-ai-advanced">
<Select value={mode} disabled={generating} onChange={(value: 'edit' | 'outpaint') => setMode(value)} options={[{ value: 'edit', label: 'AI 编辑' }, { value: 'outpaint', label: '仅扩图至 3:4(保留原图)' }]} />
<Text type="secondary">使用 Pcore 中转 · gpt-image-2{mode === 'outpaint' ? ';原图区域会覆盖回生成结果。' : ''}</Text>
</div>}
{mode === 'edit' && generateSizeChart && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{(['length', 'width', 'height'] as const).map((key, index) => (
<Fragment key={key}>
<InputNumber
min={0}
precision={2}
controls={false}
style={{ width: '100%' }}
value={dimensions[key]}
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
placeholder={['长', '宽', '高'][index]}
disabled={generating}
/>
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
</Fragment>
))}
<Select
style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'mm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
disabled={generating}
/>
</section>
</div>
</div>
)}
<Input.TextArea
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={mode === 'outpaint'
? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动'
: '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'}
disabled={generating}
/>
{mode === 'outpaint' && (
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。
</Text>
)}
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select
style={{ flex: 1, minWidth: 0 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS
.filter((m) => mode !== 'outpaint' || m.value === 'gpt-image-2@pcore')
.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
<div style={{ fontSize: 11, color: '#999' }}>
{(option as { data?: { desc?: string } }).data?.desc}
</div>
</div>
)}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 4, flexShrink: 0 }}>
<Button type="primary" loading={generating} onClick={onGenerate}>
提交任务
</Button>
</div>
</div>
</div>
<Alert
style={{ marginTop: 16 }}
type={taskError && !generating ? 'error' : 'info'}
showIcon
message={
generating
? `后台任务生成中${taskId ? `(${taskId.slice(0, 8)})` : ''},可关闭弹窗继续操作`
: taskError || '生成结果会自动追加到「生成结果」;GPT 系列单张通常需要 1-5 分钟'
}
/>
{generating && taskId && (
<Button danger type="link" size="small" onClick={() => void onCancelTask()} style={{ marginTop: 4, paddingLeft: 0 }}>
取消此任务
</Button>
)}
</div>
</div>
{resultUrl && (
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong>生成结果</Text>
<a href={resultUrl} target="_blank" rel="noreferrer">
打开原图
</a>
</Space>
<Image src={resultUrl} style={{ borderRadius: 8, maxHeight: 360, objectFit: 'contain' }} />
</div>
)}
</div>
</div>
)}
</>
);
</Drawer>
<Modal title={`选择辅助参考图 ${choosingSlot === null ? '' : choosingSlot + 1}`} open={choosingSlot !== null} footer={null} onCancel={() => setChoosingSlot(null)}>
<div className="single-ai-picker">{choices.length ? choices.map((item) => <button type="button" key={item.url} onClick={() => chooseReference(item)}><img src={item.url} alt={item.name} /><span>{item.name}</span></button>) : <Text type="secondary">暂无其他商品图片可选</Text>}</div>
</Modal>
</>;
}
+2 -2
View File
@@ -161,7 +161,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [styleSet, setStyleSet] = useState(1);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [requirements, setRequirements] = useState('');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
@@ -1230,7 +1230,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
))}
<Select
style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'mm'}
value={dimensions.unit ?? 'cm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
/>
+11 -1
View File
@@ -200,7 +200,10 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
export interface ImageEditSinglePayload {
product_id?: string;
/** 兼容旧接口的单张底图;新界面使用 image_urls。 */
/** 明确的生成底图;辅助图始终在其后。 */
main_image_url?: string;
reference_image_urls?: string[];
/** 兼容旧接口的单张底图;新界面使用 main_image_url。 */
image_url?: string;
/** 选中的参考底图,可多张。 */
image_urls?: string[];
@@ -208,6 +211,13 @@ export interface ImageEditSinglePayload {
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
mode?: 'edit' | 'outpaint';
model: string;
ratio?: '3:4' | '1:1' | '4:5';
target_language?: 'ru' | 'ar' | 'en';
translate_text?: boolean;
style_set?: number;
style_prompt?: string | null;
background?: 'auto' | 'white' | 'solid' | 'scene' | 'reference';
background_description?: string | null;
append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
after_asset_id?: string;