From 82cb69483723c1c1f03cffce82edbda99010f3c2 Mon Sep 17 00:00:00 2001 From: R524809 Date: Wed, 19 Aug 2026 17:05:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_fcf5e442-6622-45d8-971a-d78479895341.md | 48 ++++++ README.md | 9 +- extension/entrypoints/background.ts | 11 +- extension/entrypoints/panel.content.ts | 145 ++++++++++++++++++ extension/entrypoints/sidepanel/App.tsx | 27 +++- extension/src/api/client.ts | 10 ++ extension/wxt.config.ts | 14 +- server/config.py | 3 + server/schemas.py | 2 +- server/services/generator.py | 101 ++++++++++-- server/services/prompt.py | 33 ++++ 11 files changed, 381 insertions(+), 22 deletions(-) create mode 100644 .zcode/plans/plan-sess_fcf5e442-6622-45d8-971a-d78479895341.md create mode 100644 extension/entrypoints/panel.content.ts diff --git a/.zcode/plans/plan-sess_fcf5e442-6622-45d8-971a-d78479895341.md b/.zcode/plans/plan-sess_fcf5e442-6622-45d8-971a-d78479895341.md new file mode 100644 index 0000000..7ade134 --- /dev/null +++ b/.zcode/plans/plan-sess_fcf5e442-6622-45d8-971a-d78479895341.md @@ -0,0 +1,48 @@ +# 插件打开方式改造:Side Panel → 页内悬浮面板 + +## 架构(与 1688 参考插件一致,面板加载插件内置页面 ✅ 已确认) + +``` +商品详情页(Ozon / 1688 / 淘宝 / 天猫) +└─ [Shadow DOM 隔离区](不被商品页样式污染) + ├─ 右下角悬浮按钮「套」 ← 点击展开/收起 + └─ 面板 iframe(src = chrome-extension:///sidepanel.html,插件内置资源) + 悬浮覆盖在页面上 · 贴右侧 · 滑入动画 · 原页面不被挤压 +``` + +- 悬浮按钮:Shadow DOM 直接渲染(同 1688 插件 Plasmo CSUI 做法),WXT 用 `createShadowRootUi` +- 面板:iframe 悬浮覆盖(同 1688 插件),但加载插件内置页面——扩展页面在 iframe 里 chrome.* 权限齐全,现有采集/生成/轮询/导出逻辑**零改动**;唯一额外要求是 manifest 声明 `web_accessible_resources`(已查证:商品站 CSP 拦不住扩展 iframe) + +## 改动清单 + +### 1. 新增 `extension/entrypoints/panel.content.ts` —— 悬浮按钮 + 面板宿主 +- `matches` 与现有采集 content script 相同(Ozon / 1688 / 淘宝 / 天猫) +- `createShadowRootUi` 挂独立 Shadow DOM: + - **按钮**:右下角 48px 圆钮、品牌色渐变「套」;仅在商品详情页显示(复用 `matchProfile()` 判断),监听 `pushState/popstate` 兼容站内软导航 + - **面板**:fixed 贴屏幕右侧(top/bottom/right 16px),宽 `min(880px, 100vw-32px)`,圆角阴影,`translateX(110%) → 0` 滑入 0.25s,z-index 拉满 +- iframe 懒加载:首次点开才设 `src=chrome.runtime.getURL('sidepanel.html')`,关闭只隐藏不销毁(同页面内重开状态保留) +- 关闭通道:面板内 postMessage `{type:'sc-panel-close'}`(校验来源);工具栏图标 toggle 消息 + +### 2. `App.tsx` 小改(~20 行) +- `IN_PAGE = window.self !== window.top` 检测 +- IN_PAGE 时:顶栏加 ✕ 关闭按钮 + ESC 关闭 → `window.parent.postMessage` 通知宿主页收起 +- 其余逻辑不动 + +### 3. `background.ts` +- 删 `setPanelBehavior`(不再自动开 Side Panel) +- 加 `chrome.action.onClicked` → 向当前 tab 发 `{action:'toggle-suite-panel'}`(点图标也能开关面板) + +### 4. `wxt.config.ts` +- 加 `web_accessible_resources`:`sidepanel.html`,限定 6 个商品站 host +- 保留 sidePanel 权限与入口(Chrome 侧边栏仍可手动打开,作兜底) + +### 5. README 使用说明更新 + +## 已知限制 +- 面板随页面销毁:跳转其他商品页后状态重置(生成任务在服务端继续跑,只丢进度视图)。后续可选:suite_id 存 `chrome.storage.session` 做任务恢复 + +## 验证 +1. `pnpm build` → Chrome 重新加载扩展 +2. 商品页:按钮只在详情页出现;点击滑出面板、原页面不被挤压 +3. 全流程:采集 → 方案 → 生成 → 导出 ZIP +4. 关闭方式:面板 ✕ / ESC / 工具栏图标;列表页不显示按钮 \ No newline at end of file diff --git a/README.md b/README.md index f19dd26..e8e7a1d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页 ``` Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite) ┌────────────────────────────┐ ┌──────────────────────────────┐ -│ Side Panel │ │ POST /api/plan │ +│ 页内悬浮面板 │ │ POST /api/plan │ │ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案 │ │ ② 勾选/编辑素材 │ ◀─方案── │ POST /api/generate(无状态) │ │ ③ 出图方案(默认/AI规划) │ ──提交──▶ │ → 方案展开 → 逐张生图 │ @@ -71,9 +71,10 @@ pnpm build # 产物在 .output/chrome-mv3 ### 3. 使用 -1. 打开 Ozon / 1688 / 淘宝 / 天猫 的**商品详情页**,滚动到底部(详情图懒加载)后点击插件图标 -2. Side Panel:扫描 → 检查/勾选素材(默认全选主图+SKU)→ 保存到服务端 -3. 选择风格 / 图类型 / 文案语言 → 一键生成 → 完成后「导出 ZIP」 +1. 打开 Ozon / 1688 / 淘宝 / 天猫 的**商品详情页**,滚动到底部(详情图懒加载),页面右下角出现「套」悬浮按钮 +2. 点击悬浮按钮(或点击工具栏插件图标)→ 右侧滑出悬浮面板,悬浮在商品页上方、不挤压原页面 +3. 面板内:扫描 → 检查/勾选素材(默认全选主图+SKU)→ 选择风格 / 图类型 / 文案语言 → 一键生成 → 完成后「导出 ZIP」 +4. 收起面板:面板顶栏 ✕、Esc 或再点工具栏图标;同一页面内重新展开,状态保留 ## API 一览 diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts index 5fcfea2..40c4684 100644 --- a/extension/entrypoints/background.ts +++ b/extension/entrypoints/background.ts @@ -4,8 +4,15 @@ import { generateSuite, getSuite, planSuite } from '../src/api/client'; export default defineBackground(() => { console.log('[电商套图工作台] background started'); - // 点击扩展图标 → 打开 Side Panel - chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + // 点击扩展图标 → 开关当前商品页的悬浮面板(页面无 content script 时忽略) + chrome.action.onClicked.addListener(async (tab) => { + if (tab.id == null) return; + try { + await chrome.tabs.sendMessage(tab.id, { action: 'toggle-suite-panel' }); + } catch { + // 非四站点页面,未注入面板 + } + }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.action === 'generateSuite') { diff --git a/extension/entrypoints/panel.content.ts b/extension/entrypoints/panel.content.ts new file mode 100644 index 0000000..03b7074 --- /dev/null +++ b/extension/entrypoints/panel.content.ts @@ -0,0 +1,145 @@ +// Panel Content Script —— 商品页右下角悬浮按钮 + 页内悬浮面板 +// +// 面板 = iframe 加载插件内置 sidepanel.html(扩展页面在 iframe 里仍有 chrome.* 权限, +// 采集/生成/导出逻辑零改动);按钮与面板容器渲染在独立 Shadow DOM 中, +// 不受商品页全局 CSS 影响,面板悬浮覆盖页面、不挤压原页面布局。 +import { matchProfile } from '../src/profiles/index'; + +/** 面板内 App.tsx → 宿主页的收起消息 */ +const PANEL_CLOSE_MSG = 'sc-panel-close'; + +const STYLES = ` + :host { all: initial; } + + .fab { + position: fixed; + right: 24px; + bottom: 24px; + width: 48px; + height: 48px; + border-radius: 50%; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font: 700 20px/1 -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; + color: #fff; + background: linear-gradient(135deg, #8b5cf6, #6d28d9); + box-shadow: 0 4px 16px rgba(109, 40, 217, 0.45); + z-index: 3; + transition: transform 0.15s ease; + } + .fab:hover { transform: scale(1.08); } + .fab.hidden { display: none; } + + .panel { + position: fixed; + top: 0; + bottom: 0; + right: 0; + width: min(880px, 100vw); + border-radius: 14px 0 0 14px; + overflow: hidden; + background: #fff; + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.06); + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 2; + pointer-events: none; + } + .panel.open { transform: translateX(0); pointer-events: auto; } + + .panel iframe { + width: 100%; + height: 100%; + border: 0; + display: block; + background: #fff; + } +`; + +export default defineContentScript({ + matches: [ + // Ozon + 'https://*.ozon.ru/*', + 'https://*.ozon.kz/*', + 'https://*.ozon.by/*', + // 1688 + 'https://detail.1688.com/*', + // 淘宝 / 天猫 + 'https://item.taobao.com/*', + 'https://detail.tmall.com/*', + ], + async main(ctx) { + const ui = await createShadowRootUi(ctx, { + name: 'suite-studio-panel', + position: 'overlay', + anchor: 'body', + alignment: 'bottom-right', + zIndex: 2147483646, + css: STYLES, + isolateEvents: true, + onMount(container) { + const fab = document.createElement('button'); + fab.className = 'fab hidden'; + fab.title = '电商套图工作台'; + fab.textContent = '套'; + + const panel = document.createElement('div'); + panel.className = 'panel'; + // iframe 懒加载:首次展开才设 src,避免每个商品页都加载整个面板应用 + const iframe = document.createElement('iframe'); + iframe.title = '电商套图工作台'; + panel.append(iframe); + container.append(fab, panel); + + const open = () => { + if (!iframe.src) iframe.src = chrome.runtime.getURL('/sidepanel.html'); + panel.classList.add('open'); + fab.classList.add('hidden'); + // 聚焦进面板,键盘操作(Esc 关闭 / 预览翻页)直接可用 + iframe.focus(); + }; + const close = () => { + panel.classList.remove('open'); + if (isProductPage()) fab.classList.remove('hidden'); + }; + + fab.addEventListener('click', open); + + // 面板内 App(✕ / Esc)→ 收起 + window.addEventListener('message', (e) => { + if (e.source === iframe.contentWindow && (e.data as any)?.type === PANEL_CLOSE_MSG) close(); + }); + + // 工具栏图标点击 → 开关面板 + chrome.runtime.onMessage.addListener((msg: any) => { + if (msg?.action === 'toggle-suite-panel') { + panel.classList.contains('open') ? close() : open(); + } + }); + + // 仅商品详情页显示按钮;站内软导航后重判(WXT 内置事件,自动拦截 pushState/replaceState/popState) + const refresh = () => { + if (isProductPage()) { + if (!panel.classList.contains('open')) fab.classList.remove('hidden'); + } else { + fab.classList.add('hidden'); + close(); + } + }; + ctx.addEventListener(window, 'wxt:locationchange', refresh); + refresh(); + + return { open, close }; + }, + }); + ui.mount(); + }, +}); + +/** 是否为四站点支持的商品详情页(与采集 profile 一致) */ +function isProductPage(): boolean { + return matchProfile(location.href) !== null; +} diff --git a/extension/entrypoints/sidepanel/App.tsx b/extension/entrypoints/sidepanel/App.tsx index cb7cddf..07d5ec4 100644 --- a/extension/entrypoints/sidepanel/App.tsx +++ b/extension/entrypoints/sidepanel/App.tsx @@ -11,7 +11,7 @@ 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, UploadOutlined } from '@ant-design/icons'; +import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined, CloseOutlined } from '@ant-design/icons'; import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; import { buildGeneratePayload, suiteZipUrl, uploadImage, @@ -40,6 +40,14 @@ const PLATFORM_LABELS: Record = { /** 支持的站点(用于判断刷新是否有意义) */ const SUPPORTED_URL_RE = /^https?:\/\/([a-z0-9-]+\.ozon\.(ru|kz|by)|detail\.1688\.com|item\.taobao\.com|detail\.tmall\.com)\//i; +/** 页内悬浮面板模式(作为 iframe 嵌在商品页里运行);独立 Side Panel 打开时为 false */ +const IN_PAGE = window.self !== window.top; + +/** 通知宿主页收起悬浮面板 */ +function closePanel(): void { + window.parent.postMessage({ type: 'sc-panel-close' }, '*'); +} + /** 在指定 tab 执行采集,返回结果或 null(未就绪/不支持) */ async function tryScan(tabId: number): Promise { const [res] = await chrome.scripting.executeScript({ @@ -133,8 +141,8 @@ const App: React.FC = () => { // 出图方案 const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon'); const [styleSet, setStyleSet] = useState(1); - /** 生图模型(通义 DashScope,默认 wan2.7-image-pro,与后端 .env 默认一致) */ - const [model, setModel] = useState('wan2.7-image-pro'); + /** 生图模型(默认 gpt-image-2,走 RightAPI provider) */ + const [model, setModel] = useState('gpt-image-2'); /** 用户改写的风格提示词(按风格 id 存,切风格不丢) */ const [stylePrompts, setStylePrompts] = useState>({}); /** 生图要求(最高优先级,强制约束,覆盖其他设定) */ @@ -179,6 +187,16 @@ const App: React.FC = () => { return () => window.removeEventListener('keydown', onKey); }, [preview]); + // 页内面板模式:Esc 收起面板(预览打开时优先关预览,由上面的监听处理) + useEffect(() => { + if (!IN_PAGE) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !preview) closePanel(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [preview]); + const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? ''; const sales = result?.texts.find(t => t.kind === 'sales')?.content ?? ''; const shop = result?.texts.find(t => t.kind === 'shop')?.content ?? ''; @@ -500,6 +518,9 @@ const App: React.FC = () => {
{/* ── 顶栏 ── */}
+ {IN_PAGE && ( + + )}

电商套图工作台

diff --git a/extension/src/api/client.ts b/extension/src/api/client.ts index 53d45f9..12d4a6c 100644 --- a/extension/src/api/client.ts +++ b/extension/src/api/client.ts @@ -114,6 +114,16 @@ export const IMAGE_MODEL_OPTIONS = [ label: 'wan2.7-image-pro', desc: '异步精修,质感与细节更强,适合高质量电商大片', }, + { + value: 'wan2.6-image', + label: 'wan2.6-image', + desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好', + }, + { + value: 'wan2.6-t2i', + label: 'wan2.6-t2i', + desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快', + }, { value: 'gpt-image-2', label: 'gpt-image-2', diff --git a/extension/wxt.config.ts b/extension/wxt.config.ts index 3fda335..9d92110 100644 --- a/extension/wxt.config.ts +++ b/extension/wxt.config.ts @@ -32,7 +32,19 @@ export default defineConfig({ ], action: { default_title: '电商套图工作台' - } + }, + // 页内悬浮面板用 iframe 加载 sidepanel.html,必须声明为 web accessible + web_accessible_resources: [{ + resources: ['sidepanel.html'], + matches: [ + 'https://*.ozon.ru/*', + 'https://*.ozon.kz/*', + 'https://*.ozon.by/*', + 'https://detail.1688.com/*', + 'https://item.taobao.com/*', + 'https://detail.tmall.com/*', + ], + }] }, modules: ['react'] }); diff --git a/server/config.py b/server/config.py index 489287e..0d6f651 100644 --- a/server/config.py +++ b/server/config.py @@ -46,6 +46,9 @@ class Settings(BaseSettings): rightapi_base_url: str = "https://rightapi.ai/draw" rightapi_image_model: str = "gpt-image-2" rightapi_image_quality: str = "high" # auto | low | medium | high + rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试) + rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增 + rightapi_input_fidelity: str = "high" # edits 端点高保真档(high | low,留空关闭) # DeepSeek(出图方案规划器) deepseek_api_key: str = "" diff --git a/server/schemas.py b/server/schemas.py index fbcf4c1..bcf6e38 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -10,7 +10,7 @@ SUPPORTED_TYPES = [ ] # 通义(DashScope)生图模型白名单:插件下拉可选的模型 -TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro"] +TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"] # RightAPI 生图模型白名单 RIGHTAPI_MODELS = ["gpt-image-2"] diff --git a/server/services/generator.py b/server/services/generator.py index 51366e8..bd45de9 100644 --- a/server/services/generator.py +++ b/server/services/generator.py @@ -10,6 +10,7 @@ import asyncio import base64 import logging import mimetypes +import re from uuid import UUID import httpx @@ -19,21 +20,38 @@ from config import get_settings from db import get_session_factory from models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED from services import storage -from services.prompt import build_prompt, build_context, type_name +from services.prompt import build_prompt, build_context, type_name, wrap_prompt_for_gpt_edits log = logging.getLogger("suite.generator") +class ApiError(RuntimeError): + """带 HTTP 状态码的 API 错误(用于区分可重试的网关/限流错误)。""" + + def __init__(self, message: str, status: int = 0): + super().__init__(message) + self.status = status + + +_HTML_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) + + def _raise_api_error(resp, provider: str): """HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。""" if resp.is_success: return + text = resp.text or "" + if " 作摘要,避免整段 HTML 进错误信息 + m = _HTML_TITLE_RE.search(text) + detail = (re.sub(r"\s+", " ", m.group(1)).strip() if m else "") or "网关返回 HTML 错误页(上游/CDN 故障)" + raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail}", resp.status_code) try: body = resp.json() detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ') except Exception: # noqa: BLE001 - detail = resp.text[:200] - raise RuntimeError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}") + detail = text[:200] + raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}", resp.status_code) # 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面) TYPE_REF_INDEX = { @@ -42,7 +60,7 @@ TYPE_REF_INDEX = { DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张) -def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str: +def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str: """平台比例 → provider 尺寸参数。3:4 竖版(Ozon/WB),1:1 方图(国内)。""" if provider == "doubao": return "1536x2048" if ratio == "3:4" else "2048x2048" @@ -50,6 +68,9 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str: # gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法) return "1536x2048" if ratio == "3:4" else "2048x2048" # 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" if ratio == "3:4": return "1536*2048" if is_wan else "768*1024" return "2048*2048" if is_wan else "1024*1024" @@ -136,6 +157,11 @@ def _is_wan_model(model: str) -> bool: return model.lower().startswith("wan") +def _is_t2i_model(model: str) -> bool: + """纯文生图模型(如 wan2.6-t2i):不接受参考图,商品一致性只能靠文案描述。""" + return "t2i" in model.lower() + + async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, max_wait: int) -> str: poll_url = "https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id elapsed, interval = 0, 3 @@ -174,7 +200,10 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048* else "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" ) - content: list[dict] = [{"image": await _resolve_ref(u)} for u in ref_images] + # t2i 模型不接受参考图:content 只有文本,商品一致性依赖 prompt 里的标题/卖点描述 + content: list[dict] = [] + if not _is_t2i_model(model): + content = [{"image": await _resolve_ref(u)} for u in ref_images] content.append({"text": prompt}) params = {"size": size, "n": 1, "watermark": False} @@ -210,18 +239,25 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048* # ── Provider:RightAPI(gpt-image,OpenAI 兼容中转)────────────────────── -async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: +# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429) +RETRYABLE_STATUS = {429, 500, 502, 503, 504} + +# 中转对 input_fidelity 参数的支持探测:None=未探测,True=支持,False=不支持(已降级) +_rightapi_fidelity_supported: bool | None = None + + +async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes: """gpt-image 系列:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。 OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400); 同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。 + input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点); + 中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。 """ - s = get_settings() - if not s.rightapi_api_key: - raise RuntimeError("未配置 RIGHTAPI_API_KEY(.env)") - model = model or s.rightapi_image_model + global _rightapi_fidelity_supported base = s.rightapi_base_url.rstrip("/") headers = {"Authorization": f"Bearer {s.rightapi_api_key}"} + use_fidelity = bool(ref_images) and s.rightapi_input_fidelity and _rightapi_fidelity_supported is not False async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client: common = { @@ -232,12 +268,22 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204 "output_format": "jpeg", # 与落盘 .jpg 后缀一致 "n": 1, } + if use_fidelity: + common["input_fidelity"] = s.rightapi_input_fidelity if ref_images: files = [] for i, u in enumerate(ref_images): data, mime = await _resolve_ref_bytes(u) files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime))) resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common) + # 中转不认 input_fidelity:去掉参数重试一次(仅一次探测) + if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text: + _rightapi_fidelity_supported = False + log.warning("RightAPI 不支持 input_fidelity 参数,已自动去掉并降级(后续请求不再带)") + common.pop("input_fidelity", None) + resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common) + elif resp.is_success and use_fidelity: + _rightapi_fidelity_supported = True else: resp = await client.post( f"{base}/v1/images/generations", @@ -257,6 +303,36 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204 return dl.content +async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: + """带重试的 RightAPI 入口:429/5xx/超时按递增间隔重试。 + + 实测该中转对同 key 连续请求有分钟级冷却(成功一张后紧接着的请求会被网关秒拒 502), + 60s → 120s → 240s 的退避基本能等到窗口放开。 + """ + s = get_settings() + if not s.rightapi_api_key: + raise RuntimeError("未配置 RIGHTAPI_API_KEY(.env)") + model = model or s.rightapi_image_model + attempts = max(1, s.rightapi_max_retries) + + last_exc: Exception | None = None + for i in range(attempts): + try: + return await _rightapi_request(s, prompt, ref_images, size, model) + except ApiError as exc: + last_exc = exc + if exc.status not in RETRYABLE_STATUS: + raise # 参数错误等不可重试,立即失败 + except (httpx.TimeoutException, httpx.TransportError) as exc: + last_exc = exc # 网络抖动/超时可重试 + if i == attempts - 1: + break + wait = s.rightapi_retry_wait * (2 ** i) + log.warning("RightAPI 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait) + await asyncio.sleep(wait) + raise last_exc # type: ignore[misc] + + GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi} @@ -344,7 +420,7 @@ async def run_suite(suite_id: str) -> None: "rightapi": settings.rightapi_image_model, }.get(provider_name, settings.ark_image_model) is_wan = provider_name == "tongyi" and _is_wan_model(model) - size = _image_size(provider_name, suite.ratio, is_wan=is_wan) + size = _image_size(provider_name, suite.ratio, is_wan=is_wan, model=model) # 任务列表:方案(逐张)优先,旧路径按 types if suite.plan: @@ -372,6 +448,9 @@ async def run_suite(suite_id: str) -> None: type_id, ctx, suite.style_set, suite.lang, extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements, ) + # gpt-image edits 语义:商品冻结契约前置,防止风格词改商品 + if provider_name == "rightapi": + prompt = wrap_prompt_for_gpt_edits(prompt) if product: refs = await _select_ref_images(db, product.id, type_id) else: diff --git a/server/services/prompt.py b/server/services/prompt.py index 42d0092..cf1a3f8 100644 --- a/server/services/prompt.py +++ b/server/services/prompt.py @@ -116,6 +116,39 @@ DEFAULT_NEGATIVE_INTENT = ( "no distorted text, no deformed product, no extra limbs, no blurry areas" ) +# ── gpt-image(/v1/images/edits 语义)专用包装 ───────────────────────────── +# gpt-image 的 edits 端点把输入图当"被编辑的底图"、prompt 当"编辑指令"(豆包/通义则是 +# "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式: +# 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。 + +GPT_EDITS_CONTRACT = ( + "INPUT IMAGES: Image 1 (and Image 2 if present) are reference photos of ONE product " + "from different angles. Use them ONLY as the source of the product's true appearance.\n" + "PRESERVE (frozen, never change): the product itself — silhouette, proportions, colors, " + "print/pattern (keep stripes / logos / labels exactly), materials, texture, stitching, " + "hardware and every design detail. The product in the output must be the same physical " + "item as in the input images, merely photographed in a new setting.\n" + "MAY CHANGE: background, scene, props, camera angle, lighting, composition " + "and in-image marketing typography.\n" + "STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe " + "the SCENE AND BACKGROUND ONLY — never apply them to the product itself. " + "Do not restyle, recolor, re-pattern or redecorate the product. " + "You may relight the product so it sits naturally in the new scene " + "(matched shadows and color temperature), but never change its design, colors or pattern." +) + +GPT_EDITS_FINAL_CHECK = ( + "FINAL CHECK before output: if the product in your result differs from the input product in any " + "design detail (shape, color, pattern, material, logo), the image is rejected. " + "Render any listed marketing copy / headlines exactly as written (verbatim, no extra characters, " + "no paraphrasing)." +) + + +def wrap_prompt_for_gpt_edits(prompt: str) -> str: + """gpt-image edits 语义适配:契约放开头(指令权重最高处),终检放结尾。""" + return f"{GPT_EDITS_CONTRACT}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}" + # ── 商品上下文提炼 ──────────────────────────────────────────────────────── def _shorten(text: str, n: int) -> str: