feat: deepseek 的一些修改
This commit is contained in:
@@ -11,10 +11,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd';
|
||||
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
|
||||
import {
|
||||
buildGeneratePayload, suiteZipUrl,
|
||||
buildGeneratePayload, suiteZipUrl, uploadImage,
|
||||
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
|
||||
type PlanItem, type SuiteInfo,
|
||||
} from '../../src/api/client';
|
||||
@@ -121,6 +121,12 @@ const App: React.FC = () => {
|
||||
const [descEdit, setDescEdit] = useState('');
|
||||
const [paramsOpen, setParamsOpen] = useState(false);
|
||||
|
||||
// 手动上传图片(补充参考图,独立「upload」分组)
|
||||
const [uploadedImages, setUploadedImages] = useState<ImageMaterial[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const uploadSeqRef = useRef(0);
|
||||
|
||||
// 服务端
|
||||
const [settings, setSettings] = useState<BackendSettings>({ baseUrl: 'http://127.0.0.1:3300', token: '' });
|
||||
|
||||
@@ -131,6 +137,8 @@ const App: React.FC = () => {
|
||||
const [model, setModel] = useState<string>('wan2.7-image-pro');
|
||||
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
|
||||
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||||
/** 生图要求(最高优先级,强制约束,覆盖其他设定) */
|
||||
const [requirements, setRequirements] = useState('');
|
||||
const [plan, setPlan] = useState<PlanItem[]>(DEFAULT_PLAN.map(p => ({ ...p })));
|
||||
const [planSource, setPlanSource] = useState<'default' | 'ai'>('default');
|
||||
const [planSummary, setPlanSummary] = useState('');
|
||||
@@ -261,7 +269,7 @@ const App: React.FC = () => {
|
||||
if (['done', 'partial', 'failed'].includes(s.status)) {
|
||||
stopPolling();
|
||||
setGenerating(false);
|
||||
if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: '可重试或更换风格重新生成', okText: '知道了' });
|
||||
if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: s.error || '可重试或更换风格重新生成', okText: '知道了' });
|
||||
if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' });
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -314,8 +322,8 @@ const App: React.FC = () => {
|
||||
setSuite(null);
|
||||
try {
|
||||
const payload = buildGeneratePayload(
|
||||
result, selectedKeys, editedTexts(),
|
||||
{ style_set: styleSet, style_prompt: currentStylePrompt, plan: activePlan, platform, model },
|
||||
allImages, selectedKeys, editedTexts(),
|
||||
{ style_set: styleSet, style_prompt: currentStylePrompt, requirements: requirements.trim() || null, plan: activePlan, platform, model },
|
||||
);
|
||||
const { suite_id } = await send<{ suite_id: string }>('generateSuite', {
|
||||
baseUrl: settings.baseUrl, token: settings.token, payload,
|
||||
@@ -343,6 +351,7 @@ const App: React.FC = () => {
|
||||
sku_variants: skuVariants,
|
||||
image_stats: result.stats,
|
||||
platform,
|
||||
requirements: requirements.trim() || null,
|
||||
},
|
||||
});
|
||||
if (seq !== planSeqRef.current) return; // 规划已被重新采集重置,丢弃过期响应
|
||||
@@ -398,13 +407,15 @@ const App: React.FC = () => {
|
||||
setSelectedKeys(next);
|
||||
};
|
||||
|
||||
const groupImages = (groupKey: string): ImageMaterial[] =>
|
||||
result?.images.filter(i => i.groupKey === groupKey) ?? [];
|
||||
/** 全部可选图片:采集结果 + 手动上传(上传图单独一组「upload」) */
|
||||
const allImages: ImageMaterial[] = result ? [...result.images, ...uploadedImages] : uploadedImages;
|
||||
|
||||
/** 预览用的全量图序列(主图→SKU→详情,与展示顺序一致) */
|
||||
const collectedPreviewList: string[] = result
|
||||
? (['main', 'sku', 'detail'] as const).flatMap(g => groupImages(g).map(i => i.url))
|
||||
: [];
|
||||
const groupImages = (groupKey: string): ImageMaterial[] =>
|
||||
allImages.filter(i => i.groupKey === groupKey);
|
||||
|
||||
/** 预览用的全量图序列(主图→SKU→详情→上传,与展示顺序一致) */
|
||||
const collectedPreviewList: string[] = (['main', 'sku', 'detail', 'upload'] as const)
|
||||
.flatMap(g => groupImages(g).map(i => i.url));
|
||||
/** 生成结果的预览序列(仅成功的图) */
|
||||
const resultPreviewList: string[] = suite ? suite.images.filter(i => i.status === 'ok').map(i => i.url) : [];
|
||||
|
||||
@@ -418,6 +429,43 @@ const App: React.FC = () => {
|
||||
setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count } : p));
|
||||
};
|
||||
|
||||
/** 手动上传:点击触发隐藏的 file input */
|
||||
const handleUpload = () => fileRef.current?.click();
|
||||
|
||||
const onFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = ''; // 清空以便再次选择同名文件
|
||||
if (files.length === 0) return;
|
||||
setUploading(true);
|
||||
const added: ImageMaterial[] = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const { url } = await uploadImage(settings.baseUrl, settings.token, f);
|
||||
added.push({
|
||||
key: `upload-${String(++uploadSeqRef.current).padStart(3, '0')}`,
|
||||
groupKey: 'upload',
|
||||
groupName: '手动上传',
|
||||
url,
|
||||
thumbUrl: url,
|
||||
index: added.length,
|
||||
type: 'img',
|
||||
});
|
||||
} catch (err) {
|
||||
modal.error({ title: '上传失败', content: `${f.name}:${err instanceof Error ? err.message : String(err)}`, okText: '知道了' });
|
||||
}
|
||||
}
|
||||
if (added.length > 0) {
|
||||
setUploadedImages(prev => [...prev, ...added]);
|
||||
setSelectedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
added.forEach(a => next.add(a.key));
|
||||
return next;
|
||||
});
|
||||
modal.success({ title: `已上传 ${added.length} 张图片` });
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
/** 源站图防盗链时的兜底:走服务端图片代理 */
|
||||
const proxied = (u: string) =>
|
||||
`${settings.baseUrl.replace(/\/$/, '')}/api/proxy-image?url=${encodeURIComponent(u)}`;
|
||||
@@ -567,17 +615,34 @@ const App: React.FC = () => {
|
||||
no="02"
|
||||
title="采集图片"
|
||||
className="section-images"
|
||||
extra={result ? `已选 ${selectedKeys.size} / ${result.images.filter(i => i.type !== 'video').length}` : undefined}
|
||||
extra={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
{(result || uploadedImages.length > 0) && (
|
||||
<span>已选 {selectedKeys.size} / {allImages.filter(i => i.type !== 'video').length}</span>
|
||||
)}
|
||||
<button className="btn btn-sm" onClick={handleUpload} disabled={uploading}>
|
||||
<UploadOutlined /> {uploading ? '上传中…' : '上传图片'}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{!result ? (
|
||||
<div className="empty">采集后在此勾选图片</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={onFilesChange}
|
||||
/>
|
||||
{allImages.length === 0 ? (
|
||||
<div className="empty">采集后在此勾选图片,或点右上角「上传图片」手动补充</div>
|
||||
) : (
|
||||
<div className="img-groups">
|
||||
{['main', 'sku', 'detail'].map(g => groupImages(g).length > 0 && (
|
||||
{(['main', 'sku', 'detail', 'upload'] as const).map(g => groupImages(g).length > 0 && (
|
||||
<div key={g} style={{ marginBottom: 10 }}>
|
||||
<div className="group-head">
|
||||
<span className="name">
|
||||
{g === 'main' ? '主图' : g === 'sku' ? 'SKU图片' : '详情图'}
|
||||
{g === 'main' ? '主图' : g === 'sku' ? 'SKU图片' : g === 'detail' ? '详情图' : '手动上传'}
|
||||
</span>
|
||||
<span className="count">{groupImages(g).length}</span>
|
||||
<span
|
||||
@@ -647,6 +712,7 @@ const App: React.FC = () => {
|
||||
<span className="plan-title">{p.title}</span>
|
||||
{p.variant_name && <span className="variant-chip">{p.variant_name}</span>}
|
||||
{p.detail && <span className="plan-detail" title={p.detail}>{p.detail}</span>}
|
||||
{p.prompt_hint && <span className="plan-detail plan-hint" title={p.prompt_hint}>🎯 {p.prompt_hint}</span>}
|
||||
</div>
|
||||
<Stepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
|
||||
</div>
|
||||
@@ -698,6 +764,15 @@ const App: React.FC = () => {
|
||||
onChange={(e) => setStylePrompts(p => ({ ...p, [styleSet]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>生图要求(优先级最高,强制要求,会覆盖其他设定)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={requirements}
|
||||
onChange={(e) => setRequirements(e.target.value)}
|
||||
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
|
||||
{generating && (
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -773,7 +848,9 @@ const App: React.FC = () => {
|
||||
<div style={{ aspectRatio: suite.ratio === '3:4' ? '3/4' : '1', background: 'var(--card-soft)' }} />
|
||||
)}
|
||||
{img.status !== 'ok' && <span className="fail-tag">{img.status === 'failed' ? '✗' : '…'}</span>}
|
||||
<div className="cap">{img.name}</div>
|
||||
<div className="cap" title={img.error || img.name}>
|
||||
{img.status === 'failed' && img.error ? img.error : img.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -275,6 +275,8 @@
|
||||
font-size: 12px; color: var(--text-2);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
/* 构图提示(prompt_hint):生图要求的落地处,用主题蓝区分 */
|
||||
.plan-detail.plan-hint { color: #4f6bed; }
|
||||
.stepper { display: inline-flex; align-items: center; gap: 0; flex-shrink: 0; }
|
||||
.step-btn {
|
||||
width: 24px; height: 24px; border: 1px solid var(--border-strong); background: #fff;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
|
||||
* 契约对齐 server 端 /api/materials 与 /api/suites。
|
||||
*/
|
||||
import type { ScanResult } from '../collector/scan';
|
||||
import type { ScanResult, ImageMaterial } from '../collector/scan';
|
||||
|
||||
export interface MaterialsPayload {
|
||||
product_id: string | null;
|
||||
@@ -218,22 +218,23 @@ export interface GeneratePayload {
|
||||
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
|
||||
style_set: number;
|
||||
style_prompt?: string;
|
||||
requirements?: string | null;
|
||||
plan: PlanItem[];
|
||||
platform: string;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片 + 出图方案 */
|
||||
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
|
||||
export function buildGeneratePayload(
|
||||
result: ScanResult,
|
||||
images: ImageMaterial[],
|
||||
selectedKeys: Set<string>,
|
||||
texts: GeneratePayload['texts'],
|
||||
config: { style_set: number; style_prompt?: string; plan: PlanItem[]; platform: string; model?: string | null },
|
||||
config: { style_set: number; style_prompt?: string; requirements?: string | null; plan: PlanItem[]; platform: string; model?: string | null },
|
||||
): GeneratePayload {
|
||||
const images = result.images
|
||||
const selected = images
|
||||
.filter((img) => selectedKeys.has(img.key))
|
||||
.map((img) => ({ url: img.url, group_key: img.groupKey, variant_name: img.variantName ?? null }));
|
||||
return { texts, images, ...config };
|
||||
return { texts, images: selected, ...config };
|
||||
}
|
||||
|
||||
/** 出图方案规划请求体 */
|
||||
@@ -242,6 +243,7 @@ export interface PlanPayload {
|
||||
sku_variants: string[];
|
||||
image_stats: Record<string, number>;
|
||||
platform: string;
|
||||
requirements?: string | null;
|
||||
}
|
||||
|
||||
/** AI 智能规划:DeepSeek 根据商品信息生成出图方案 */
|
||||
@@ -289,3 +291,21 @@ export async function getSuite(baseUrl: string, token: string, suiteId: string):
|
||||
export function suiteZipUrl(baseUrl: string, suiteId: string): string {
|
||||
return `${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}/zip`;
|
||||
}
|
||||
|
||||
/** 手动上传本地图片到服务端,返回可访问 URL(补充参考图用) */
|
||||
export async function uploadImage(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
file: File,
|
||||
): Promise<{ url: string; key: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token), // 不显式设 Content-Type,交给浏览器生成 boundary
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export type TextKind =
|
||||
| 'sales'
|
||||
| 'shop';
|
||||
|
||||
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
|
||||
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video' | 'upload';
|
||||
|
||||
export type SrcProp =
|
||||
| 'data-lazyload-src'
|
||||
|
||||
@@ -92,6 +92,7 @@ async def generate_suite(
|
||||
product_id=None,
|
||||
style_set=req.style_set,
|
||||
style_prompt=req.style_prompt,
|
||||
requirements=req.requirements,
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
@@ -125,7 +126,7 @@ async def plan_suite(req: PlanRequest) -> PlanResponse:
|
||||
if not product_info.get("title"):
|
||||
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
||||
try:
|
||||
result = await generate_plan(product_info, req.sku_variants, req.image_stats, req.platform)
|
||||
result = await generate_plan(product_info, req.sku_variants, req.image_stats, req.platform, requirements=req.requirements)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
||||
@@ -82,6 +82,7 @@ async def create_suite(
|
||||
suite = Suite(
|
||||
product_id=product.id,
|
||||
style_set=req.style_set,
|
||||
requirements=req.requirements,
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""手动上传图片:插件用户在采集区手动补充参考图,转存本地 media 供预览与生图。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["upload"])
|
||||
|
||||
# content-type → 落盘扩展名
|
||||
_ALLOWED_TYPES = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
}
|
||||
|
||||
MAX_BYTES = 20 * 1024 * 1024 # 20MB
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
async def upload_image(file: UploadFile = File(...)):
|
||||
data = await file.read()
|
||||
ctype = (file.content_type or "").split(";")[0].strip().lower()
|
||||
if ctype not in _ALLOWED_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的图片类型: {file.content_type}")
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="空文件")
|
||||
if len(data) > MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="图片超过 20MB")
|
||||
key = storage.write_bytes(data, key_prefix="uploads", ext=_ALLOWED_TYPES[ctype])
|
||||
return {"url": storage.public_url(key), "key": key}
|
||||
@@ -45,6 +45,8 @@ async def _migrate(conn) -> None:
|
||||
cols = {row[1] for row in rows}
|
||||
if "model" not in cols:
|
||||
await conn.execute(text("ALTER TABLE suites ADD COLUMN model VARCHAR(64)"))
|
||||
if "requirements" not in cols:
|
||||
await conn.execute(text("ALTER TABLE suites ADD COLUMN requirements TEXT"))
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import collection, generate, products, proxy, suites
|
||||
from api import collection, generate, products, proxy, suites, upload
|
||||
from config import get_settings
|
||||
from db import init_db
|
||||
from services.storage import media_root
|
||||
@@ -36,6 +36,7 @@ app.include_router(products.router)
|
||||
app.include_router(suites.router)
|
||||
app.include_router(generate.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(upload.router)
|
||||
|
||||
# 静态托管生成的图片/转存素材
|
||||
app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
|
||||
|
||||
@@ -86,6 +86,7 @@ class Suite(Base):
|
||||
status: Mapped[str] = mapped_column(String(16), default=SUITE_PENDING, index=True)
|
||||
style_set: Mapped[int] = mapped_column(Integer, default=1) # 风格模板 1-5
|
||||
style_prompt: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户改写的风格提示词(覆盖模板)
|
||||
requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # 生图要求(最高优先级,强制约束)
|
||||
platform: Mapped[str] = mapped_column(String(8), default="cn") # 目标平台 ozon | wb | cn
|
||||
lang: Mapped[str] = mapped_column(String(4), default="zh") # ru / zh(由平台推导)
|
||||
ratio: Mapped[str] = mapped_column(String(8), default="1:1") # 图片比例(由平台推导)
|
||||
|
||||
@@ -68,6 +68,7 @@ class SuiteCreateRequest(BaseModel):
|
||||
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
||||
provider: str | None = Field(default=None, description="覆盖默认 provider(doubao | tongyi)")
|
||||
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro)")
|
||||
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束)")
|
||||
|
||||
|
||||
# ── 无状态套图生成(工具流程:请求自带采集数据)──
|
||||
@@ -93,6 +94,7 @@ class GenerateRequest(BaseModel):
|
||||
images: list[GenerateImageItem] = Field(default_factory=list, description="勾选的参考图")
|
||||
style_set: int = Field(default=1, ge=1, le=7)
|
||||
style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
|
||||
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)")
|
||||
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
|
||||
plan: list[PlanItem] | None = Field(default=None, description="出图方案(优先于 types)")
|
||||
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
||||
@@ -107,6 +109,7 @@ class PlanRequest(BaseModel):
|
||||
sku_variants: list[str] = Field(default_factory=list, description="带图的 SKU 规格名")
|
||||
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计")
|
||||
platform: str = Field(default="cn")
|
||||
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
|
||||
|
||||
|
||||
class PlanItemOut(BaseModel):
|
||||
|
||||
@@ -296,6 +296,7 @@ async def run_suite(suite_id: str) -> None:
|
||||
]
|
||||
|
||||
ok, failed = 0, 0
|
||||
failures: list[str] = []
|
||||
for job in jobs:
|
||||
type_id = job["kind"]
|
||||
image_row = SuiteImage(
|
||||
@@ -309,7 +310,7 @@ async def run_suite(suite_id: str) -> None:
|
||||
try:
|
||||
prompt = build_prompt(
|
||||
type_id, ctx, suite.style_set, suite.lang,
|
||||
extra=job, style_prompt=suite.style_prompt,
|
||||
extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements,
|
||||
)
|
||||
if product:
|
||||
refs = await _select_ref_images(db, product.id, type_id)
|
||||
@@ -322,13 +323,22 @@ async def run_suite(suite_id: str) -> None:
|
||||
ok += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id)
|
||||
image_row.error = str(exc)[:500]
|
||||
err = str(exc)[:500]
|
||||
image_row.error = err
|
||||
failures.append(f"{job.get('title') or type_name(type_id)}:{err[:200]}")
|
||||
failed += 1
|
||||
await db.commit()
|
||||
|
||||
suite.status = SUITE_DONE if failed == 0 else (SUITE_PARTIAL if ok > 0 else SUITE_FAILED)
|
||||
if failed and not ok:
|
||||
suite.error = "全部生成失败,请检查 API Key / 参考图"
|
||||
if failed:
|
||||
uniq = list(dict.fromkeys(failures)) # 去重保序
|
||||
detail = ";".join(uniq[:6])
|
||||
if len(uniq) > 6:
|
||||
detail += f";…等共 {failed} 张失败"
|
||||
if ok == 0:
|
||||
suite.error = f"全部生成失败。{detail}"
|
||||
else:
|
||||
suite.error = f"部分生成失败({failed} 张)。{detail}"
|
||||
from datetime import datetime, timezone
|
||||
suite.finished_at = datetime.now(timezone.utc)
|
||||
if product:
|
||||
|
||||
@@ -44,6 +44,31 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
|
||||
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
|
||||
|
||||
|
||||
def _system_prompt_with_requirements(requirements: str | None) -> str:
|
||||
"""把生图要求作为最高优先级约束注入 system prompt(置于规划规则之前)。
|
||||
|
||||
不仅声明优先级,还明确要求把要求落地到每个方案项的 prompt_hint,
|
||||
避免模型只把要求当作背景信息而不影响输出。
|
||||
"""
|
||||
if not (requirements and requirements.strip()):
|
||||
return SYSTEM_PROMPT
|
||||
marker = "\n## 输出硬性约束"
|
||||
idx = SYSTEM_PROMPT.find(marker)
|
||||
if idx < 0:
|
||||
return SYSTEM_PROMPT
|
||||
req = requirements.strip()
|
||||
block = (
|
||||
"\n## 生图要求(最高优先级,硬性约束,覆盖下方所有规划规则与约束)\n"
|
||||
+ req
|
||||
+ "\n\n"
|
||||
+ "规划方案时,必须把上述生图要求落地到每一项:\n"
|
||||
+ "1. 每个方案项的 prompt_hint 必须融入上述要求的关键约束(如要求纯黑背景,则每个 prompt_hint 都要写明 black background);\n"
|
||||
+ "2. title / detail 措辞不得与上述要求矛盾;\n"
|
||||
+ "3. 任何规划规则与上述要求冲突时,一律以本生图要求为准。\n"
|
||||
)
|
||||
return SYSTEM_PROMPT[:idx] + block + SYSTEM_PROMPT[idx:]
|
||||
|
||||
|
||||
def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
"""清洗模型输出:kind 白名单、count 钳制、variant 必须真实存在。"""
|
||||
items: list[dict] = []
|
||||
@@ -127,18 +152,26 @@ async def generate_plan(
|
||||
sku_variants: list[str],
|
||||
image_stats: dict,
|
||||
platform: str,
|
||||
requirements: str | None = None,
|
||||
) -> dict:
|
||||
"""调用 DeepSeek 生成方案。返回 {summary, items}。"""
|
||||
"""调用 DeepSeek 生成方案。返回 {summary, items}。
|
||||
|
||||
requirements:生图要求,最高优先级注入 system prompt,规划方案必须遵循。
|
||||
"""
|
||||
s = get_settings()
|
||||
if not s.deepseek_api_key:
|
||||
raise RuntimeError("未配置 DEEPSEEK_API_KEY(.env)")
|
||||
|
||||
user_content = json.dumps({
|
||||
user_payload: dict = {
|
||||
"商品信息": product_info, # {title, desc, params:[{key,value}], sellingPoints, price}
|
||||
"SKU规格": sku_variants, # 带图的 SKU 规格名(variant_name 只能从中选)
|
||||
"图片统计": image_stats, # {main: n, sku: n, detail: n}
|
||||
"目标平台": platform, # ozon/wb/cn(决定图内文案语言)
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
# 生图要求同时在 user 侧强调(与 system prompt 双重约束),确保模型真正遵循
|
||||
if requirements and requirements.strip():
|
||||
user_payload["生图要求(最高优先级,必须体现在每个方案项中)"] = requirements.strip()
|
||||
user_content = json.dumps(user_payload, ensure_ascii=False)
|
||||
|
||||
async with httpx.AsyncClient(timeout=90, verify=False) as client:
|
||||
resp = await client.post(
|
||||
@@ -147,7 +180,7 @@ async def generate_plan(
|
||||
json={
|
||||
"model": s.deepseek_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "system", "content": _system_prompt_with_requirements(requirements)},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
|
||||
@@ -283,12 +283,14 @@ _PROMPT_BUILDERS = {
|
||||
|
||||
|
||||
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
|
||||
style_prompt: str | None = None) -> str:
|
||||
style_prompt: str | None = None, requirements: str | None = None) -> str:
|
||||
"""构造指定图类型的完整生图 prompt。
|
||||
|
||||
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
|
||||
预设类型也会把 prompt_hint 作为构图补充注入。
|
||||
style_prompt: 用户改写的风格提示词,覆盖 style_set 内置模板(tone/bg 整体替换)。
|
||||
requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面,
|
||||
声明覆盖一切冲突指令,用户可在此输入强制要求。
|
||||
"""
|
||||
if style_prompt and style_prompt.strip():
|
||||
style = {"name": "custom", "tone": style_prompt.strip(), "bg": ""}
|
||||
@@ -305,6 +307,15 @@ def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
if hint:
|
||||
prompt = prompt.rstrip(".") + f". Additional composition guidance: {hint}."
|
||||
# 生图要求:最高优先级,置于最前并声明覆盖冲突指令(用户输入原样保留,不翻译)
|
||||
if requirements and requirements.strip():
|
||||
prompt = (
|
||||
"STRICT REQUIREMENTS (highest priority, must be followed exactly, "
|
||||
"override any conflicting instruction): "
|
||||
+ requirements.strip().rstrip(".")
|
||||
+ ". "
|
||||
+ prompt
|
||||
)
|
||||
return prompt + ". " + DEFAULT_NEGATIVE_INTENT
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user