From dbb2bb80bd2d637c8c71bb7123643b2807dfb6eb Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 11 Sep 2026 17:28:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=9F=E5=9B=BE=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=B0=BA=E5=AF=B8=E5=A1=AB=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/suite.py | 32 ++++++++++-- server/schemas/suite.py | 13 +++++ server/services/planner.py | 12 +++-- server/services/prompts/common.py | 21 +++++--- start.command | 25 +++++---- studio/src/pages/trial/AiImageGenModal.tsx | 61 +++++++++++++++++++--- studio/src/pages/trial/TrialSuitePanel.tsx | 41 +++++++++++++-- studio/src/services/suite.ts | 18 ++++++- 8 files changed, 188 insertions(+), 35 deletions(-) diff --git a/server/api/suite.py b/server/api/suite.py index 58350b2..ab820e1 100644 --- a/server/api/suite.py +++ b/server/api/suite.py @@ -50,6 +50,27 @@ from services.suite_service import texts_to_raw, validate_model router = APIRouter(prefix="/api", tags=["suite"]) +def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict: + """将表单长宽高放入提示词上下文,不依赖采集参数是否已有尺寸字段。""" + if dimensions is None: + if required: + raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高") + return raw + values = (dimensions.length, dimensions.width, dimensions.height) + if not all(value is not None and value > 0 for value in values): + if required: + raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高") + return raw + enriched = dict(raw) + enriched["dimensions"] = { + "length": dimensions.length, + "width": dimensions.width, + "height": dimensions.height, + "unit": dimensions.unit, + } + return enriched + + def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]: """以固定块返回临时 ZIP,释放响应完成后的临时文件。""" try: @@ -64,7 +85,7 @@ def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iter @router.post("/suite/plan", response_model=PlanResponse) async def plan_suite(req: SuitePlanRequest) -> PlanResponse: """DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。""" - product_info = texts_to_raw(req.texts) + product_info = _with_dimensions(texts_to_raw(req.texts), req.dimensions) if not product_info.get("title"): raise HTTPException(status_code=400, detail="缺少商品标题,无法规划") try: @@ -140,7 +161,7 @@ async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse: ratio=spec["ratio"], style_set=req.style_set, request_payload={ - "context": texts_to_raw(req.texts), + "context": _with_dimensions(texts_to_raw(req.texts), req.dimensions), "style_prompt": req.style_prompt, "requirements": req.requirements, "watermark": req.watermark.model_dump() if req.watermark else None, @@ -295,6 +316,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc # 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。 + is_size_chart = bool(req.generate_size_chart) task = await create_image_job( product_id=product_id, kind="image_edit", @@ -306,7 +328,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse ratio=spec["ratio"], style_set=5, request_payload={ - "context": {"title": "AI 精修"}, + "context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart), "style_prompt": None, "requirements": req.prompt, "watermark": req.watermark.model_dump() if req.watermark else None, @@ -316,8 +338,8 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse }, item_payloads=[{ "position": 0, - "kind": "custom", - "title": "AI生图", + "kind": "size_chart" if is_size_chart else "custom", + "title": "尺寸标注图" if is_size_chart else "AI生图", "detail": "", "prompt_hint": "", "variant_name": None, diff --git a/server/schemas/suite.py b/server/schemas/suite.py index de0cca8..3dcaff8 100644 --- a/server/schemas/suite.py +++ b/server/schemas/suite.py @@ -76,6 +76,15 @@ class WatermarkOptions(BaseModel): opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)") +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="尺寸单位") + + class PlanItem(BaseModel): """出图方案项:一类图 × 数量,可绑定 SKU 规格。""" kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)") @@ -94,6 +103,7 @@ class SuitePlanRequest(BaseModel): image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}") platform: str = Field(default="ozon") requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)") + dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于规划尺寸标注图") class PlanItemOut(BaseModel): @@ -129,6 +139,7 @@ class SuiteGenerateRequest(BaseModel): platform: str = Field(default="ozon") model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)") watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传") + dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图") class SuiteCreateResponse(BaseModel): @@ -177,6 +188,8 @@ class ImageEditSingleRequest(BaseModel): description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾", ) watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传") + generate_size_chart: bool = Field(default=False, description="是否按尺寸标注图生成") + dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图") class ImageEditTaskResponse(BaseModel): diff --git a/server/services/planner.py b/server/services/planner.py index 0009889..77c815c 100644 --- a/server/services/planner.py +++ b/server/services/planner.py @@ -23,10 +23,9 @@ ALLOWED_KINDS = [ ] # 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费): -# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15; -# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。 -PLAN_SAFETY_MAX = 15 -PLAN_REQUIREMENTS_MAX = 24 +# AI 智能规划最多生成 8 张;即使用户在要求中指定更高张数也不突破此上限。 +PLAN_SAFETY_MAX = 8 +PLAN_REQUIREMENTS_MAX = 8 MAX_VARIANT_MAIN_IMAGES = 2 # 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图, @@ -53,7 +52,7 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规 1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。 2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。 3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。 -4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。 +4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,最多 8 张;若「生图要求」明确指定了张数,按指定张数输出但仍不得超过 8 张。 5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。 ## 规划规则 @@ -126,6 +125,9 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]: def _has_dimensions(product_info: dict) -> bool: """仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。""" + dimensions = product_info.get("dimensions") or {} + if isinstance(dimensions, dict) and all(dimensions.get(key) is not None for key in ("length", "width", "height")): + return True for param in product_info.get("params") or []: if not isinstance(param, dict): continue diff --git a/server/services/prompts/common.py b/server/services/prompts/common.py index 531c308..a806bae 100644 --- a/server/services/prompts/common.py +++ b/server/services/prompts/common.py @@ -133,6 +133,12 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") - if len(selling_points) >= 5: break + dimensions = raw.get("dimensions") or {} + dimension_values = (dimensions.get("length"), dimensions.get("width"), dimensions.get("height")) if isinstance(dimensions, dict) else () + dimensions_line = "" + if len(dimension_values) == 3 and all(value is not None for value in dimension_values): + unit = dimensions.get("unit") or "mm" + dimensions_line = "L × W × H: " + " × ".join(str(value) for value in dimension_values) + f" {unit}" params_line = "; ".join( f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8] ) @@ -141,13 +147,16 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") - "title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译 "desc": desc, "selling_points": selling_points[:3], - "params_line": params_line, + "params_line": "; ".join(filter(None, (params_line, dimensions_line))), # 俄文图片里不能直接渲染中文参数;保留不含汉字的规格(型号、数字、单位)供尺寸图使用。 - "params_line_non_cjk": "; ".join( - f"{p.get('key')}: {p.get('value')}" - for p in (raw.get("params") or [])[:8] - if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}") - ), + "params_line_non_cjk": "; ".join(filter(None, ( + "; ".join( + f"{p.get('key')}: {p.get('value')}" + for p in (raw.get("params") or [])[:8] + if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}") + ), + dimensions_line, + ))), "price": raw.get("price") or "", } diff --git a/start.command b/start.command index ba3c8d7..10e388f 100755 --- a/start.command +++ b/start.command @@ -9,6 +9,13 @@ BACKEND_URL="http://${BACKEND_HOST}:${BACKEND_PORT}" STUDIO_URL="http://localhost:8900" RUNTIME_DIR="$PROJECT_DIR/data/runtime" INSTANCE_PID_FILE="$RUNTIME_DIR/start-command.pid" +# 两个前端项目均锁定 pnpm 10.32.1。系统的 `pnpm` 是 Corepack shim, +# 其默认版本可能随 Node 更新而变化,因此显式指定项目所需的版本。 +PNPM_VERSION="10.32.1" + +project_pnpm() { + corepack "pnpm@${PNPM_VERSION}" "$@" +} # 由当前 start.command 管理的子进程。它们都在清理函数里按 PID 树结束, # 不扫描或误杀其他项目的 Python / Node 进程。 @@ -141,7 +148,7 @@ watch_collector() { if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then touch "$COLLECTOR_STAMP" echo "🔧 检测到插件代码改动,重新构建…" - if pnpm -C extensions/collector build >/dev/null 2>&1; then + if project_pnpm -C extensions/collector build >/dev/null 2>&1; then echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效" else echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错" @@ -152,13 +159,13 @@ watch_collector() { if [[ ! -d extensions/collector ]]; then echo "⚠️ 未找到 extensions/collector,跳过采集插件构建" -elif ! command -v pnpm >/dev/null 2>&1; then - echo "⚠️ 未找到 pnpm,本次跳过采集插件构建(可手动:pnpm -C extensions/collector build)" -elif ! CI=true pnpm -C extensions/collector install --silent; then +elif ! command -v corepack >/dev/null 2>&1; then + echo "⚠️ 未找到 Corepack,本次跳过采集插件构建(可手动:corepack pnpm@${PNPM_VERSION} -C extensions/collector build)" +elif ! CI=true project_pnpm -C extensions/collector install --silent; then echo "⚠️ 采集插件依赖安装失败,本次跳过构建" else echo "正在构建采集插件…" - if pnpm -C extensions/collector build; then + if project_pnpm -C extensions/collector build; then echo "采集插件已构建:extensions/collector/.output/chrome-mv3" else echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)" @@ -182,11 +189,11 @@ supervise_worker & WORKER_SUPERVISOR_PID=$! echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)" -if ! command -v pnpm >/dev/null 2>&1; then - echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)" +if ! command -v corepack >/dev/null 2>&1; then + echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)" # CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认 -elif CI=true pnpm -C studio install --silent; then - pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器 +elif CI=true project_pnpm -C studio install --silent; then + project_pnpm -C studio dev & # vite:8900,启动后会自动打开浏览器 STUDIO_PID=$! else echo "⚠️ studio 依赖安装失败,本次只启动后端" diff --git a/studio/src/pages/trial/AiImageGenModal.tsx b/studio/src/pages/trial/AiImageGenModal.tsx index 71ca331..2a3c6d2 100644 --- a/studio/src/pages/trial/AiImageGenModal.tsx +++ b/studio/src/pages/trial/AiImageGenModal.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; -import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd'; +import { Fragment, useEffect, useRef, useState } from 'react'; +import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Select, Space, Typography } from 'antd'; import { CloseOutlined } from '@ant-design/icons'; import { apiErrorMessage } from '@/services/api'; -import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, WatermarkPayload } from '@/services/suite'; +import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite'; const { Text } = Typography; @@ -27,6 +27,8 @@ interface Props { export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) { const [prompt, setPrompt] = useState(''); const [model, setModel] = useState(DEFAULT_IMAGE_MODEL); + const [dimensions, setDimensions] = useState({ unit: 'mm' }); + const [generateSizeChart, setGenerateSizeChart] = useState(false); const [generating, setGenerating] = useState(false); const [resultUrl, setResultUrl] = useState(''); const [taskId, setTaskId] = useState(''); @@ -48,6 +50,8 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe useEffect(() => { if (open && !activeTaskRef.current) { setPrompt(''); + setDimensions({ unit: 'mm' }); + setGenerateSizeChart(false); setResultUrl(''); setGenerating(false); setTaskId(''); @@ -126,6 +130,12 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe const onGenerate = async () => { if (!source) return; + const hasCompleteDimensions = [dimensions.length, dimensions.width, dimensions.height] + .every((value) => typeof value === 'number' && value > 0); + if (generateSizeChart && !hasCompleteDimensions) { + message.warning('生成尺寸标注图前,请完整填写商品长、宽、高'); + return; + } setGenerating(true); setResultUrl(''); setTaskError(''); @@ -138,6 +148,8 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe append: true, after_asset_id: source.afterAssetId, watermark: watermark?.enabled ? watermark : undefined, + generate_size_chart: generateSizeChart, + dimensions: hasCompleteDimensions ? dimensions : null, }); setTaskId(r.suite_id); message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示'); @@ -209,6 +221,33 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe />
+
+ 商品尺寸(长 × 宽 × 高) +
+ {(['length', 'width', 'height'] as const).map((key, index) => ( + + setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))} + placeholder={['长', '宽', '高'][index]} + disabled={generating} + /> + {index < 2 && ×} + + ))} + setDimensions((prev) => ({ ...prev, unit }))} + options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]} + /> +
+
生图要求(优先级最高,强制约束,会覆盖其他设定) diff --git a/studio/src/services/suite.ts b/studio/src/services/suite.ts index 12ee729..b541ae4 100644 --- a/studio/src/services/suite.ts +++ b/studio/src/services/suite.ts @@ -32,7 +32,10 @@ export interface PlanItem { } /** 默认方案:7 种基础类型各 1 张(AI 规划前) */ -export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) => ({ +export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS + .filter((t) => t.value !== 'multi_scene') + .slice(0, 7) + .map((t) => ({ kind: t.value, title: t.label, detail: '', @@ -41,6 +44,14 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) = variant_name: null, })); +/** 商品外廓尺寸;单位可选 mm 或 cm。 */ +export interface ProductDimensions { + length?: number; + width?: number; + height?: number; + unit?: 'mm' | 'cm'; +} + /** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */ export const STYLE_SET_OPTIONS = [ { value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' }, @@ -100,6 +111,7 @@ export interface SuitePlanPayload { image_stats: Record; platform: string; requirements?: string | null; + dimensions?: ProductDimensions | null; } export interface SuiteGeneratePayload { @@ -113,6 +125,7 @@ export interface SuiteGeneratePayload { platform: string; model?: string | null; watermark?: WatermarkPayload; + dimensions?: ProductDimensions | null; } export interface SuiteImageInfo { @@ -193,6 +206,9 @@ export interface ImageEditSinglePayload { after_asset_id?: string; /** 单张生成完成后由服务端合成水印;关闭时不传。 */ watermark?: WatermarkPayload; + /** 勾选时将当前图按尺寸标注图任务处理。 */ + generate_size_chart?: boolean; + dimensions?: ProductDimensions | null; } /** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */