feat: 插件改为浮窗模式

This commit is contained in:
R524809
2026-08-19 17:05:23 +08:00
parent 06b220ae8d
commit 82cb694837
11 changed files with 381 additions and 22 deletions
@@ -0,0 +1,48 @@
# 插件打开方式改造:Side Panel → 页内悬浮面板
## 架构(与 1688 参考插件一致,面板加载插件内置页面 ✅ 已确认)
```
商品详情页(Ozon / 1688 / 淘宝 / 天猫)
└─ [Shadow DOM 隔离区](不被商品页样式污染)
├─ 右下角悬浮按钮「套」 ← 点击展开/收起
└─ 面板 iframesrc = chrome-extension://<id>/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.25sz-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 / 工具栏图标;列表页不显示按钮
+5 -4
View File
@@ -11,7 +11,7 @@ Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页
``` ```
Chrome 插件(WXT + React + antd Python 后端(FastAPI + SQLite Chrome 插件(WXT + React + antd Python 后端(FastAPI + SQLite
┌────────────────────────────┐ ┌──────────────────────────────┐ ┌────────────────────────────┐ ┌──────────────────────────────┐
Side Panel │ │ POST /api/plan │ 页内悬浮面板 │ │ POST /api/plan │
│ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案 │ │ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案 │
│ ② 勾选/编辑素材 │ ◀─方案── │ POST /api/generate(无状态) │ │ ② 勾选/编辑素材 │ ◀─方案── │ POST /api/generate(无状态) │
│ ③ 出图方案(默认/AI规划) │ ──提交──▶ │ → 方案展开 → 逐张生图 │ │ ③ 出图方案(默认/AI规划) │ ──提交──▶ │ → 方案展开 → 逐张生图 │
@@ -71,9 +71,10 @@ pnpm build # 产物在 .output/chrome-mv3
### 3. 使用 ### 3. 使用
1. 打开 Ozon / 1688 / 淘宝 / 天猫 的**商品详情页**,滚动到底部(详情图懒加载)后点击插件图标 1. 打开 Ozon / 1688 / 淘宝 / 天猫 的**商品详情页**,滚动到底部(详情图懒加载),页面右下角出现「套」悬浮按钮
2. Side Panel:扫描 → 检查/勾选素材(默认全选主图+SKU)→ 保存到服务端 2. 点击悬浮按钮(或点击工具栏插件图标)→ 右侧滑出悬浮面板,悬浮在商品页上方、不挤压原页面
3. 选择风格 / 图类型 / 文案语言 → 一键生成 → 完成后「导出 ZIP」 3. 面板内:扫描 → 检查/勾选素材(默认全选主图+SKU)→ 选择风格 / 图类型 / 文案语言 → 一键生成 → 完成后「导出 ZIP」
4. 收起面板:面板顶栏 ✕、Esc 或再点工具栏图标;同一页面内重新展开,状态保留
## API 一览 ## API 一览
+9 -2
View File
@@ -4,8 +4,15 @@ import { generateSuite, getSuite, planSuite } from '../src/api/client';
export default defineBackground(() => { export default defineBackground(() => {
console.log('[电商套图工作台] background started'); console.log('[电商套图工作台] background started');
// 点击扩展图标 → 打开 Side Panel // 点击扩展图标 → 开关当前商品页的悬浮面板(页面无 content script 时忽略)
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); 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) => { chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'generateSuite') { if (msg?.action === 'generateSuite') {
+145
View File
@@ -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;
}
+24 -3
View File
@@ -11,7 +11,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd'; 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 type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import { import {
buildGeneratePayload, suiteZipUrl, uploadImage, buildGeneratePayload, suiteZipUrl, uploadImage,
@@ -40,6 +40,14 @@ const PLATFORM_LABELS: Record<string, string> = {
/** 支持的站点(用于判断刷新是否有意义) */ /** 支持的站点(用于判断刷新是否有意义) */
const SUPPORTED_URL_RE = /^https?:\/\/([a-z0-9-]+\.ozon\.(ru|kz|by)|detail\.1688\.com|item\.taobao\.com|detail\.tmall\.com)\//i; 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(未就绪/不支持) */ /** 在指定 tab 执行采集,返回结果或 null(未就绪/不支持) */
async function tryScan(tabId: number): Promise<ScanResult | null> { async function tryScan(tabId: number): Promise<ScanResult | null> {
const [res] = await chrome.scripting.executeScript({ const [res] = await chrome.scripting.executeScript({
@@ -133,8 +141,8 @@ const App: React.FC = () => {
// 出图方案 // 出图方案
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon'); const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon');
const [styleSet, setStyleSet] = useState(1); const [styleSet, setStyleSet] = useState(1);
/** 生图模型(通义 DashScope,默认 wan2.7-image-pro,与后端 .env 默认一致 */ /** 生图模型(默认 gpt-image-2,走 RightAPI provider */
const [model, setModel] = useState<string>('wan2.7-image-pro'); const [model, setModel] = useState<string>('gpt-image-2');
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */ /** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({}); const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
/** 生图要求(最高优先级,强制约束,覆盖其他设定) */ /** 生图要求(最高优先级,强制约束,覆盖其他设定) */
@@ -179,6 +187,16 @@ const App: React.FC = () => {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [preview]); }, [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 brand = result?.texts.find(t => t.kind === 'brand')?.content ?? '';
const sales = result?.texts.find(t => t.kind === 'sales')?.content ?? ''; const sales = result?.texts.find(t => t.kind === 'sales')?.content ?? '';
const shop = result?.texts.find(t => t.kind === 'shop')?.content ?? ''; const shop = result?.texts.find(t => t.kind === 'shop')?.content ?? '';
@@ -500,6 +518,9 @@ const App: React.FC = () => {
<div className="page"> <div className="page">
{/* ── 顶栏 ── */} {/* ── 顶栏 ── */}
<div className="topbar"> <div className="topbar">
{IN_PAGE && (
<button className="icon-btn" title="收起面板(Esc" onClick={closePanel}><CloseOutlined /></button>
)}
<div className="logo"></div> <div className="logo"></div>
<div> <div>
<h1></h1> <h1></h1>
+10
View File
@@ -114,6 +114,16 @@ export const IMAGE_MODEL_OPTIONS = [
label: 'wan2.7-image-pro', label: 'wan2.7-image-pro',
desc: '异步精修,质感与细节更强,适合高质量电商大片', 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', value: 'gpt-image-2',
label: 'gpt-image-2', label: 'gpt-image-2',
+13 -1
View File
@@ -32,7 +32,19 @@ export default defineConfig({
], ],
action: { action: {
default_title: '电商套图工作台' 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'] modules: ['react']
}); });
+3
View File
@@ -46,6 +46,9 @@ class Settings(BaseSettings):
rightapi_base_url: str = "https://rightapi.ai/draw" rightapi_base_url: str = "https://rightapi.ai/draw"
rightapi_image_model: str = "gpt-image-2" rightapi_image_model: str = "gpt-image-2"
rightapi_image_quality: str = "high" # auto | low | medium | high 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(出图方案规划器)
deepseek_api_key: str = "" deepseek_api_key: str = ""
+1 -1
View File
@@ -10,7 +10,7 @@ SUPPORTED_TYPES = [
] ]
# 通义(DashScope)生图模型白名单:插件下拉可选的模型 # 通义(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 生图模型白名单
RIGHTAPI_MODELS = ["gpt-image-2"] RIGHTAPI_MODELS = ["gpt-image-2"]
+90 -11
View File
@@ -10,6 +10,7 @@ import asyncio
import base64 import base64
import logging import logging
import mimetypes import mimetypes
import re
from uuid import UUID from uuid import UUID
import httpx import httpx
@@ -19,21 +20,38 @@ from config import get_settings
from db import get_session_factory 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 models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED
from services import storage 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") 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"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
def _raise_api_error(resp, provider: str): def _raise_api_error(resp, provider: str):
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。""" """HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
if resp.is_success: if resp.is_success:
return return
text = resp.text or ""
if "<html" in text[:300].lower() or text.lstrip()[:15].lower().startswith("<!doctype"):
# Cloudflare/网关错误页:取 <title> 作摘要,避免整段 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: try:
body = resp.json() body = resp.json()
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ') detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
detail = resp.text[:200] detail = text[:200]
raise RuntimeError(f"{provider} API HTTP {resp.status_code}{detail or '无错误详情'}") raise ApiError(f"{provider} API HTTP {resp.status_code}{detail or '无错误详情'}", resp.status_code)
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面) # 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
TYPE_REF_INDEX = { TYPE_REF_INDEX = {
@@ -42,7 +60,7 @@ TYPE_REF_INDEX = {
DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张) 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 方图(国内)。""" """平台比例 → provider 尺寸参数。3:4 竖版(Ozon/WB),1:1 方图(国内)。"""
if provider == "doubao": if provider == "doubao":
return "1536x2048" if ratio == "3:4" else "2048x2048" 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 合法) # gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048" return "1536x2048" if ratio == "3:4" else "2048x2048"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同 # 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": if ratio == "3:4":
return "1536*2048" if is_wan else "768*1024" return "1536*2048" if is_wan else "768*1024"
return "2048*2048" if is_wan else "1024*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") 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: 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 poll_url = "https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id
elapsed, interval = 0, 3 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" 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}) content.append({"text": prompt})
params = {"size": size, "n": 1, "watermark": False} params = {"size": size, "n": 1, "watermark": False}
@@ -210,18 +239,25 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
# ── ProviderRightAPIgpt-imageOpenAI 兼容中转)────────────────────── # ── ProviderRightAPIgpt-imageOpenAI 兼容中转)──────────────────────
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/editsmultipart),无参考图走 /v1/images/generations。 """gpt-image 系列:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400); OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。 同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点);
中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。
""" """
s = get_settings() global _rightapi_fidelity_supported
if not s.rightapi_api_key:
raise RuntimeError("未配置 RIGHTAPI_API_KEY.env")
model = model or s.rightapi_image_model
base = s.rightapi_base_url.rstrip("/") base = s.rightapi_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"} 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: async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
common = { common = {
@@ -232,12 +268,22 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204
"output_format": "jpeg", # 与落盘 .jpg 后缀一致 "output_format": "jpeg", # 与落盘 .jpg 后缀一致
"n": 1, "n": 1,
} }
if use_fidelity:
common["input_fidelity"] = s.rightapi_input_fidelity
if ref_images: if ref_images:
files = [] files = []
for i, u in enumerate(ref_images): for i, u in enumerate(ref_images):
data, mime = await _resolve_ref_bytes(u) data, mime = await _resolve_ref_bytes(u)
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime))) 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) 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: else:
resp = await client.post( resp = await client.post(
f"{base}/v1/images/generations", 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 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} 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, "rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model) }.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(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 # 任务列表:方案(逐张)优先,旧路径按 types
if suite.plan: if suite.plan:
@@ -372,6 +448,9 @@ async def run_suite(suite_id: str) -> None:
type_id, ctx, suite.style_set, suite.lang, type_id, ctx, suite.style_set, suite.lang,
extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements, 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: if product:
refs = await _select_ref_images(db, product.id, type_id) refs = await _select_ref_images(db, product.id, type_id)
else: else:
+33
View File
@@ -116,6 +116,39 @@ DEFAULT_NEGATIVE_INTENT = (
"no distorted text, no deformed product, no extra limbs, no blurry areas" "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: def _shorten(text: str, n: int) -> str: