diff --git a/.env.example b/.env.example index cac0c8c..f16ec0f 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,13 @@ DASHSCOPE_API_KEY= DASHSCOPE_BASE_URL= DASHSCOPE_MODEL=wan2.7-image-pro +# --- RightAPI(gpt-image,OpenAI 兼容中转)--- +RIGHTAPI_API_KEY= +RIGHTAPI_BASE_URL=https://rightapi.ai/draw +RIGHTAPI_IMAGE_MODEL=gpt-image-2 +# 出图质量:auto | low | medium | high(high 单张约 1-5 分钟,超时自动兜底 600s) +RIGHTAPI_IMAGE_QUALITY=high + # --- DeepSeek(出图方案规划器)--- DEEPSEEK_API_KEY= DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 diff --git a/README.md b/README.md index fb53116..f19dd26 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite - `generator.py`:图像 provider(图生图,参考图 = 采集主图) - `doubao`:火山方舟 Seedream(默认,`ARK_API_KEY`) - `tongyi`:通义万相/千问(`DASHSCOPE_API_KEY`,wan* 异步轮询 / qwen* 同步) + - `rightapi`:gpt-image-2(OpenAI 兼容中转 `RIGHTAPI_API_KEY`,edits 参考图 / generations 同步) + - 插件只传模型名,服务端按模型名自动路由到对应 provider ## 快速开始 diff --git a/bak.1env b/bak.1env new file mode 100644 index 0000000..b4928e4 --- /dev/null +++ b/bak.1env @@ -0,0 +1,35 @@ +# ===== 服务 ===== +# 注意:不要用 5000/7000(macOS 隔空播放接收器占用) +HOST=127.0.0.1 +PORT=3300 +# 拼给插件/前端的媒体访问地址(默认本机) +APP_BASE_URL=http://127.0.0.1:3300 + +# ===== 存储 ===== +# 图片/数据库落盘目录(默认 <项目根>/data) +# DATA_DIR=/Users/joey/sites/seller-store/image-suite-studio/data + +# ===== 图像生成 ===== +# 默认 provider:doubao(火山方舟 Seedream)| tongyi(阿里 DashScope)| rightapi(gpt-image) +IMAGE_PROVIDER=doubao +# 单张生图请求超时(秒) +REQUEST_TIMEOUT=300 +# 异步任务轮询上限(秒) +POLL_MAX_WAIT=600 + +# --- 豆包 / 火山方舟 --- +ARK_API_KEY= +ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3/images/generations +ARK_IMAGE_MODEL=doubao-seedream-4-5-251128 + +# --- 通义 / DashScope --- +DASHSCOPE_API_KEY= +DASHSCOPE_BASE_URL= +DASHSCOPE_MODEL=wan2.7-image-pro + +# --- RightAPI(gpt-image,OpenAI 兼容中转)--- +RIGHTAPI_API_KEY=sk-b6b9ffba28b64ca795be31f6dee842c6 +RIGHTAPI_BASE_URL=https://rightapi.ai/draw +RIGHTAPI_IMAGE_MODEL=gpt-image-2 +# 出图质量:auto | low | medium | high(high 单张约 1-5 分钟,超时自动兜底 600s) +RIGHTAPI_IMAGE_QUALITY=high diff --git a/extension/package.json b/extension/package.json index 3807445..4e9df64 100644 --- a/extension/package.json +++ b/extension/package.json @@ -20,5 +20,6 @@ "@types/react-dom": "^18.3.0", "typescript": "^5.5.3", "wxt": "^0.19.0" - } + }, + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" } diff --git a/extension/src/api/client.ts b/extension/src/api/client.ts index b27d578..53d45f9 100644 --- a/extension/src/api/client.ts +++ b/extension/src/api/client.ts @@ -102,7 +102,7 @@ export const PLATFORM_OPTIONS = [ export type PlatformId = (typeof PLATFORM_OPTIONS)[number]['value']; -/** 生图模型(通义 DashScope):下拉可选 + 中文特点说明 */ +/** 生图模型(下拉可选 + 中文特点说明;服务端按模型名路由 provider) */ export const IMAGE_MODEL_OPTIONS = [ { value: 'qwen-image-3.0-pro', @@ -114,6 +114,11 @@ export const IMAGE_MODEL_OPTIONS = [ label: 'wan2.7-image-pro', desc: '异步精修,质感与细节更强,适合高质量电商大片', }, + { + value: 'gpt-image-2', + label: 'gpt-image-2', + desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟', + }, ] as const; export const PLATFORM_SPECS: Record = { diff --git a/server/api/generate.py b/server/api/generate.py index d84cac9..a994090 100644 --- a/server/api/generate.py +++ b/server/api/generate.py @@ -7,7 +7,8 @@ from config import get_settings from db import get_db from models import Suite from schemas import ( - GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateResponse, TextMaterial, + GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS, + SuiteCreateResponse, TextMaterial, resolve_provider, PlanRequest, PlanResponse, PlanItemOut, ) from services.generator import run_suite @@ -84,10 +85,13 @@ async def generate_suite( spec = PLATFORM_SPECS[req.platform] settings = get_settings() - provider_name = req.provider or settings.image_provider + # 插件只传模型名:已知模型直接路由到对应 provider(gpt-image-2 → rightapi) + provider_name = resolve_provider(req.model, req.provider, settings.image_provider) model = req.model if provider_name == "tongyi" and model and model not in TONGYI_MODELS: raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})") + if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS: + raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})") suite = Suite( product_id=None, style_set=req.style_set, diff --git a/server/api/suites.py b/server/api/suites.py index 1916592..0b728c2 100644 --- a/server/api/suites.py +++ b/server/api/suites.py @@ -13,7 +13,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from config import get_settings from db import get_db from models import Product, ProductAsset, Suite, SuiteImage, STATUS_OK -from schemas import PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut +from schemas import ( + PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS, + SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut, resolve_provider, +) from services import storage from services.generator import run_suite @@ -76,9 +79,12 @@ async def create_suite( spec = PLATFORM_SPECS[req.platform] settings = get_settings() - provider_name = req.provider or settings.image_provider + # 模型名优先路由:已知模型直接定位 provider(gpt-image-2 → rightapi) + provider_name = resolve_provider(req.model, req.provider, settings.image_provider) if provider_name == "tongyi" and req.model and req.model not in TONGYI_MODELS: raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}(tongyi 支持: {TONGYI_MODELS})") + if provider_name == "rightapi" and req.model and req.model not in RIGHTAPI_MODELS: + raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}(rightapi 支持: {RIGHTAPI_MODELS})") suite = Suite( product_id=product.id, style_set=req.style_set, diff --git a/server/config.py b/server/config.py index b994429..489287e 100644 --- a/server/config.py +++ b/server/config.py @@ -41,6 +41,12 @@ class Settings(BaseSettings): dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点 dashscope_model: str = "wan2.7-image-pro" + # RightAPI(OpenAI 兼容中转,gpt-image 系列) + rightapi_api_key: str = "" + rightapi_base_url: str = "https://rightapi.ai/draw" + rightapi_image_model: str = "gpt-image-2" + rightapi_image_quality: str = "high" # auto | low | medium | high + # DeepSeek(出图方案规划器) deepseek_api_key: str = "" deepseek_base_url: str = "https://api.deepseek.com/v1" diff --git a/server/main.py b/server/main.py index ac81496..8e84991 100644 --- a/server/main.py +++ b/server/main.py @@ -50,6 +50,7 @@ async def health(): "provider": settings.image_provider, "ark_configured": bool(settings.ark_api_key), "dashscope_configured": bool(settings.dashscope_api_key), + "rightapi_configured": bool(settings.rightapi_api_key), } diff --git a/server/schemas.py b/server/schemas.py index b0780c7..fbcf4c1 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -12,6 +12,22 @@ SUPPORTED_TYPES = [ # 通义(DashScope)生图模型白名单:插件下拉可选的模型 TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro"] +# RightAPI 生图模型白名单 +RIGHTAPI_MODELS = ["gpt-image-2"] + +# 模型 → provider 推断表:插件只传模型名,服务端据此路由(模型名优先于 provider 字段) +MODEL_PROVIDERS: dict[str, str] = { + **{m: "tongyi" for m in TONGYI_MODELS}, + **{m: "rightapi" for m in RIGHTAPI_MODELS}, +} + + +def resolve_provider(model: str | None, requested: str | None, default: str) -> str: + """已知模型名直接定位 provider;未知模型回退到请求指定的 provider 或默认值。""" + if model and model in MODEL_PROVIDERS: + return MODEL_PROVIDERS[model] + return requested or default + # ── 采集上传 ── diff --git a/server/services/generator.py b/server/services/generator.py index e4822c8..51366e8 100644 --- a/server/services/generator.py +++ b/server/services/generator.py @@ -46,6 +46,9 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str: """平台比例 → provider 尺寸参数。3:4 竖版(Ozon/WB),1:1 方图(国内)。""" if provider == "doubao": return "1536x2048" if ratio == "3:4" else "2048x2048" + if provider == "rightapi": + # gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法) + return "1536x2048" if ratio == "3:4" else "2048x2048" # tongyi:万象与千问的 size 语法相同(* 分隔),档位不同 if ratio == "3:4": return "1536*2048" if is_wan else "768*1024" @@ -70,27 +73,34 @@ def _bytes_to_data_uri(data: bytes, mime: str) -> str: return f"data:{mime};base64,{base64.b64encode(data).decode()}" -async def _resolve_ref(url: str) -> str: - """参考图 URL → data URI。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。 +async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]: + """参考图 URL → (bytes, mime)。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。 生图 API 的服务器无法访问 127.0.0.1,代理 URL 也不能直接透传, - 所以统一在本地解析成 base64 data URI 再进请求体。 + 所以统一在本地解析成原始字节再进请求体(data URI 或 multipart)。 """ if url.startswith("data:"): - return url + head, _, b64 = url.partition(",") + mime = head[5:].split(";", 1)[0] or "image/jpeg" + return base64.b64decode(b64), mime path = storage.local_path(url) if path is not None: mime = mimetypes.guess_type(path.name)[0] or "image/jpeg" - return _bytes_to_data_uri(path.read_bytes(), mime) + return path.read_bytes(), mime if url.startswith(("http://", "https://")): from api.proxy import guess_referer data, ctype = await storage.download_bytes(url, referer=guess_referer(url)) if not ctype.startswith("image/"): ctype = "image/jpeg" - return _bytes_to_data_uri(data, ctype) + return data, ctype raise FileNotFoundError(f"无法解析参考图: {url}") +async def _resolve_ref(url: str) -> str: + data, mime = await _resolve_ref_bytes(url) + return _bytes_to_data_uri(data, mime) + + # ── Provider:豆包 Seedream(火山方舟)──────────────────────────────────── async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: @@ -198,7 +208,56 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048* return dl.content -GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi} +# ── Provider:RightAPI(gpt-image,OpenAI 兼容中转)────────────────────── + +async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: + """gpt-image 系列:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。 + + OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400); + 同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。 + """ + s = get_settings() + 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("/") + headers = {"Authorization": f"Bearer {s.rightapi_api_key}"} + + async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client: + common = { + "model": model, + "prompt": prompt, + "size": size, + "quality": s.rightapi_image_quality, + "output_format": "jpeg", # 与落盘 .jpg 后缀一致 + "n": 1, + } + 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) + else: + resp = await client.post( + f"{base}/v1/images/generations", + headers={**headers, "Content-Type": "application/json"}, + json=common, + ) + _raise_api_error(resp, "RightAPI") + item = resp.json()["data"][0] + b64 = item.get("b64_json") or "" + if b64: + return base64.b64decode(b64.split(",", 1)[-1] if "," in b64 else b64) + img_url = item.get("url") or "" + if not img_url: + raise RuntimeError(f"RightAPI 响应里没有图片数据: {item}") + dl = await client.get(img_url, timeout=s.request_timeout) + dl.raise_for_status() + return dl.content + + +GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi} # ── 任务执行器 ──────────────────────────────────────────────────────────── @@ -280,9 +339,10 @@ async def run_suite(suite_id: str) -> None: raw = suite.context if not product else (product.raw or {}) ctx = build_context(raw or {}, fallback_name=product.name if product else "") - model = suite.model or ( - settings.dashscope_model if provider_name == "tongyi" else settings.ark_image_model - ) + model = suite.model or { + "tongyi": settings.dashscope_model, + "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) @@ -317,7 +377,9 @@ async def run_suite(suite_id: str) -> None: else: refs = _refs_for_job(list(suite.ref_images or []), job) data = await generator(prompt, refs, size=size, model=model) - key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=".jpg") + # 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名 + ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg" + key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=ext) image_row.stored_url = storage.public_url(key) image_row.status = STATUS_OK ok += 1