feat: 插件改为浮窗模式
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# 插件打开方式改造:Side Panel → 页内悬浮面板
|
||||
|
||||
## 架构(与 1688 参考插件一致,面板加载插件内置页面 ✅ 已确认)
|
||||
|
||||
```
|
||||
商品详情页(Ozon / 1688 / 淘宝 / 天猫)
|
||||
└─ [Shadow DOM 隔离区](不被商品页样式污染)
|
||||
├─ 右下角悬浮按钮「套」 ← 点击展开/收起
|
||||
└─ 面板 iframe(src = 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.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 / 工具栏图标;列表页不显示按钮
|
||||
@@ -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 一览
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
/** 支持的站点(用于判断刷新是否有意义) */
|
||||
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<ScanResult | null> {
|
||||
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<string>('wan2.7-image-pro');
|
||||
/** 生图模型(默认 gpt-image-2,走 RightAPI provider) */
|
||||
const [model, setModel] = useState<string>('gpt-image-2');
|
||||
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
|
||||
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||||
/** 生图要求(最高优先级,强制约束,覆盖其他设定) */
|
||||
@@ -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 = () => {
|
||||
<div className="page">
|
||||
{/* ── 顶栏 ── */}
|
||||
<div className="topbar">
|
||||
{IN_PAGE && (
|
||||
<button className="icon-btn" title="收起面板(Esc)" onClick={closePanel}><CloseOutlined /></button>
|
||||
)}
|
||||
<div className="logo">套</div>
|
||||
<div>
|
||||
<h1>电商套图工作台</h1>
|
||||
|
||||
@@ -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',
|
||||
|
||||
+13
-1
@@ -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']
|
||||
});
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _raise_api_error(resp, provider: str):
|
||||
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
|
||||
if resp.is_success:
|
||||
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:
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user