feat: AI 生图、图片下载优化
This commit is contained in:
+4
-2
@@ -38,8 +38,10 @@ APP_BASE_URL=http://127.0.0.1:8800
|
||||
# ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)──
|
||||
# 豆包 Seedream(火山方舟)
|
||||
ARK_API_KEY=
|
||||
# RightAPI 中转(gpt-image-2 / gpt-image-2-vip / nano-banana 系列)
|
||||
# RightCode 中转 rightapi.ai(gpt-image-2 / gpt-image-2-vip / nano-banana 系列)
|
||||
RIGHTAPI_API_KEY=
|
||||
# 可选覆盖:默认生图 provider(doubao | tongyi | rightapi)、水印徽章图路径
|
||||
# Pcore 中转 pcore.ai(模型下拉里选「gpt-image-2(Pcore)」时使用)
|
||||
PCORE_API_KEY=
|
||||
# 可选覆盖:默认生图 provider(doubao | tongyi | rightapi | pcore)、水印徽章图路径
|
||||
# IMAGE_PROVIDER=doubao
|
||||
# WATERMARK_IMAGE_PATH=server/assets/watermark.jpg
|
||||
|
||||
+86
-46
@@ -1,7 +1,7 @@
|
||||
# 工作交接说明(HANDOFF)
|
||||
|
||||
> 更新:2026-08-28 晚
|
||||
> 读者:下一个接手工作的 AI / 开发者。先读本文再动手,避免重复探索。
|
||||
> 更新:2026-08-29 晚
|
||||
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
|
||||
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`image-suite.md`](./image-suite.md)(生图)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
|
||||
|
||||
---
|
||||
@@ -22,78 +22,118 @@ extensions/collector 扩展采集(Ozon/1688/淘宝/天猫)
|
||||
## 2. 启动与构建
|
||||
|
||||
```bash
|
||||
# server(我最后用的方式;日志在 /tmp/osk-server.log)
|
||||
cd ozon-seller-kit
|
||||
# 一键启动(推荐):自动装依赖 → 构建采集插件 → 起插件改动监听 → 起 studio → 起 server
|
||||
./start.command
|
||||
|
||||
# server 单独起(等价;日志建议重定向到 /tmp/osk-server.log)
|
||||
.venv/bin/uvicorn main:app --app-dir server --reload --reload-include "*.yaml" --host 127.0.0.1 --port 8800
|
||||
|
||||
# studio
|
||||
pnpm -C studio dev # http://localhost:8900,/api 代理到 8800
|
||||
|
||||
# 扩展(改了扩展代码后必跑,然后 chrome://extensions 刷新)
|
||||
pnpm -C extensions/collector install # 首次
|
||||
# 扩展改动后(start.command 的监听会自动构建,仍需去 chrome://extensions 手动点刷新)
|
||||
pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
||||
```
|
||||
|
||||
密钥在根目录 `.env`(已配好):`DEEPSEEK_API_KEY`(规划+文案)、`RIGHTAPI_API_KEY`(生图主力:gpt-image-2-vip / nano-banana 系列)、`DASHSCOPE_API_KEY`(通义,可选)、`ARK_API_KEY` 未配(豆包未用)。
|
||||
- **start.command 新增**:启动时自动构建采集插件,并起后台监听(3s 轮询 `src/**` + `wxt.config.ts`,改动自动重建;BSD find 语法,无新依赖)。Ctrl+C 会连同监听进程一起清理。
|
||||
- **运行态提醒**:交接时 server 是上个会话用 nohup 起的后台进程(日志 `/tmp/osk-server.log`),原 start.command 终端已被停掉。新会话建议:`kill $(lsof -ti :8800)` 后用 `./start.command` 正常接管。
|
||||
- **`uvicorn --reload` 不监控 `.env`**:改 `.env` 后必须手动重启 server(Kill 8800 端口进程再起)。
|
||||
- 密钥在根目录 `.env`:`DEEPSEEK_API_KEY`(规划+文案)、`DASHSCOPE_API_KEY`(通义,可用 ✅)、`RIGHTAPI_API_KEY`(RightCode 中转,**通道当前故障** ❌)、`PCORE_API_KEY`(pcore.ai,**可用 ✅**)、`ARK_API_KEY` 未配(豆包未用)。`.env.example` 有全部键位说明。
|
||||
|
||||
## 3. 当前进度
|
||||
## 3. 生图供应商现状(2026-08-29,重要)
|
||||
|
||||
### ✅ 已完成(已提交,git log 有记录)
|
||||
| 阶段 | 内容 |
|
||||
| 供应商 | 域名 | 模型(下拉显示) | 状态 |
|
||||
|---|---|---|---|
|
||||
| 通义/阿里 | dashscope.aliyuncs.com | `qwen-image-3.0-pro` 等(阿里巴巴) | ✅ 可用,实测 10s 出图 |
|
||||
| RightCode | rightapi.ai | `gpt-image-2`、`gpt-image-2-vip`、`nano-banana` 系列(RightCode) | ❌ 通道故障:任务能提交但生成阶段 503/「No available channel」,纯文生图也失败;中转侧问题,待其恢复 |
|
||||
| Pcore | pcore.ai | `gpt-image-2@pcore`(Pcore) | ✅ 可用(用户已在 pcore 开通 image2 分组后端到端跑通,全链路约 1 分钟/张) |
|
||||
|
||||
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
|
||||
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
|
||||
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits` 且 images 必须是 **`[{image_url: dataURI}]` 对象数组**(纯字符串报错);均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 是枚举(1024x1536/1024x1024),不接受自定义尺寸。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
|
||||
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);生图全局串行(`generator.py` 的 `asyncio.Lock`),远碰不到 RPM。pcore 账户余额 $5,按张扣费。
|
||||
|
||||
## 4. AI 规划张数策略(`planner.py`)
|
||||
|
||||
- **不设硬性数量**:prompt 让 AI 按商品复杂度自主决定,「建议 8 张左右」;规划规则 = 先保证每个 SKU 1 张主图,其余类型自行取舍。
|
||||
- **用户可通过「生图要求」指定张数**:写了「N 张」AI 必须严格执行;服务端 `_total_budget()` 用正则提取,安全上限:默认 15、指定时按 N 最大 24(防跑飞,超限从尾部截断,SKU 主图优先保留)。
|
||||
- **规划器曾频繁报「规划器未返回有效方案项」**:根因是 deepseek-v4 是思维链模型而规划器裸调用没禁 thinking,token 被推理烧光、正文为空。已修复:请求体加 `"thinking": {"type": "disabled"}` + 解析失败/空方案自动重试一次 + 失败时记录原始输出 + 缺 title 的方案项从 detail/kind 兜底合成不再丢弃。30 SKU 压测 3/3 通过。
|
||||
|
||||
## 5. 当前进度
|
||||
|
||||
### ✅ 已完成(含 2026-08-29 一轮,**全部未提交**,见 §6)
|
||||
|
||||
| 模块 | 内容 |
|
||||
|---|---|
|
||||
| Phase A | 试算页前端 `/trial/:id` 五区块:1 商品信息 / 2 价格试算(公式对齐 v1 web)/ 3 俄文文案 / 4 采集图片+出图方案+生成结果 / 5 入库与导出 |
|
||||
| Phase C | ISS 扩展并入 `extensions/collector/`;「上报商品」按钮(01 区块下方)→ 入库 + 新标签打开试算页;设置弹窗含上报开关/后台地址/试算页地址 |
|
||||
| Phase B | 生图服务端(`server/api/suite.py` + `services/{planner,generator,prompts/,tasks,watermark}`,平移自 image-suite-studio) |
|
||||
| 后端整理 | 鉴权全删(server+studio);冻结链路隔离 `server/legacy/`(接口仍挂载);`collection.py`→`materials.py`;suite 业务下沉 `services/suite_service.py` |
|
||||
| 试算页 | 五区块页面、计价、俄文文案、采集图片/出图方案/生成结果、登记导出 |
|
||||
| 采集对接 | ISS 扩展并入 `extensions/collector/`,「上报商品」入库 + 自动打开试算页 |
|
||||
| 生图服务端 | planner/generator/prompts/watermark/tasks,套图 + 单张 AI 生图 + 水印 + ZIP 导出 |
|
||||
| **本轮:Pcore 接入** | 新 provider 全链路(生成/编辑/轮询/重试/错误处理),`@pcore` 路由约定,下拉带供应商名,默认模型切 Pcore |
|
||||
| **本轮:规划器修复** | thinking 禁用 + 重试 + 张数策略(AI 自主,建议 8 张左右,生图要求可指定) |
|
||||
| **本轮:采集箱** | 勾选列 + 全选/取消全选 + 批量删除(硬删、Popconfirm 二次确认、删空页自动回退) |
|
||||
| **本轮:采集图片区改版** | 8 列网格;AI 生图按钮半透明悬浮图片右下角;图名显示在图片下方(取 URL 末段);SKU 名并入图名行(不再悬浮遮挡勾选钮);勾选钮在右上角;默认全部不勾选 |
|
||||
| **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 POST /api/export/images,ZIP 内分「套图结果/单张生图」文件夹);「单张 AI 生图」小节改名「生图记录」(落库历史,刷新后保留) |
|
||||
| **本轮:关键 bug 修复** | ① axios 全局 Content-Type 导致 FormData 被 JSON 化 → 上传 422(`api.ts` 移除全局头,**今后勿设**);② TrialPage `load()` 置 loading 导致整页闪 Spin、卸载面板(杀生图轮询/丢勾选)→ 改静默刷新;③ web v1 页面因 cdn.tailwindcss.com 被墙整页无样式 → tailwind 运行时固化到 `web/vendor/tailwind.js` |
|
||||
| **本轮:杂项** | 采买地址「打开地址」改图标按钮(与复制图标同款);start.command 自动构建+监听插件 |
|
||||
|
||||
### ✅ 已联调验证(真实调用生图 API)
|
||||
plan / generate(串行队列+轮询+回写 generated)/ image-edit(单张,含 `after_asset_id` 插入原图后)/ 水印(文字 Panda Store 右下角)/ export/images(ZIP 分组结构)/ proxy-image / DELETE /api/assets/{id}(删素材+文件)/ materials 上报(product_id 返回)。
|
||||
### 验证口径(本轮均实测通过)
|
||||
|
||||
### 🔨 最近一轮 UI 迭代(**部分未提交**,见 git status)
|
||||
- 出图方案卡:左右 12:12;左列灰卡内含「规划并生成 + AI 智能规划」;数量改 Stepper(− 输入 +,0-5);AI 智能规划按钮高度与一键生图一致;说明文字支持换行
|
||||
- 水印设置移到「6 生成结果」标题栏最右;默认文本改 `Panda Store`
|
||||
- 生成结果卡:新增「单张 AI 生图」小节(含套图回写与单张图);底部「AI 编辑」可点击 → 同款生图弹窗,新图经 `after_asset_id` 插在原图后;每张图右上角 ✕ 删除(Popconfirm 二次确认 → `DELETE /api/assets/{id}`)
|
||||
- 采集图片卡不再显示 generated 素材(归生成结果卡)
|
||||
- 型号/货号:后缀独立存储(货号框 `addonBefore` 展示 `型号-`,只输后缀;型号/货号框后有复制按钮);型号含 `-` 不再增殖
|
||||
- 侧边栏:展开 200 / 折叠 60,折叠图标居中(`SidebarMenu.css` 尾部规则)
|
||||
- 采买地址:「打开地址」按钮 + 复制图标;仅 1688/淘宝/天猫/拼多多来源自动填充
|
||||
- 试算页水印弹窗默认文本 Panda Store(注意 localStorage `trialWatermark` 有旧值需手改一次)
|
||||
- 上传/生图/导出/批量删除/规划,全部真实接口+浏览器操作验证;Pcore 端到端 56s 出图并回写素材。
|
||||
- 每个 UI 改动后 `pnpm -C studio exec tsc -b` 零错误;插件构建通过;`/api/health` 200。
|
||||
|
||||
## 4. 下一步(按优先级)
|
||||
## 6. ⚠️ 未提交改动清单(当前最大事项)
|
||||
|
||||
1. **提交当前改动**:`git status` 有一批未提交(后端整理 + 最近 UI 迭代 + extensions settings 改动),建议按模块分 2-3 个 commit
|
||||
2. **手动全流程回归**(浏览器 + Chrome 真机扩展):采集 → 上报 → 试算页五区块 → 生成/编辑/删除 → 登记表录入/导出 CSV/组合码 → 清空
|
||||
3. **Phase D 待办**:批量 CSV 服务端化(`GET /api/export/trial-csv`,替代前端逐个拉详情拼装);登记表数据当前存 localStorage(换设备不可见,如需跨设备要落库)
|
||||
4. **远期**:鉴权/账户体系重做(从 `server/deps.py` 层重新引入);ISS-only 产品模式构建配置;`extension-v1/v2`、`web/`、`server/legacy/` 的清理时机
|
||||
分支 `feat-2.1.0`,工作区有 19 个文件改动 + 1 个新目录,**建议按模块分 3-4 个 commit**:
|
||||
|
||||
## 5. 已知坑(新会话必读)
|
||||
1. **Pcore 生图接入 + 规划器修复 + 张数策略**
|
||||
`server/services/{generator,planner,suite_service,prompts/__init__}.py`、`server/{api/suite.py,schemas/suite.py,config/settings.py}`、`studio/src/services/suite.ts`、`.env.example`、`docs/v2.1/{image-suite,api}.md`
|
||||
2. **试算页 UI 迭代 + 采集箱批量操作 + 请求层修复**
|
||||
`studio/src/pages/trial/{TrialSuitePanel,TrialInfoPanel,TrialPage}.tsx`、`studio/src/pages/collection/CollectionPage.tsx`、`studio/src/services/api.ts`
|
||||
3. **web v1 样式修复(CDN 固化)+ start.command 插件自动构建**
|
||||
`web/ozonSeller.html`、`web/vendor/tailwind.js`(新目录)、`start.command`
|
||||
4. **交接文档**:`docs/v2.1/HANDOFF.md`
|
||||
|
||||
提交前:`git checkout -- studio/tsconfig.app.tsbuildinfo`(构建产物,勿提交)。
|
||||
|
||||
## 7. 下一步(按优先级)
|
||||
|
||||
1. **提交**:按 §6 分组提交。
|
||||
2. **手动全流程回归**(浏览器 + Chrome 真机扩展):采集 → 上报 → 试算页五区块 → 生成(Pcore)/编辑/删除 → 登记表录入/导出 CSV/组合码 → 清空。
|
||||
3. **运营观察**:pcore 余额($5 起步)与 image2 分组日限(1.2K 张/天);RightCode 通道恢复情况(恢复后 nano-banana 系列可回归)。
|
||||
4. **Phase D 待办**:批量 CSV 服务端化(`GET /api/export/trial-csv`);登记表数据存 localStorage(跨设备需落库)。
|
||||
5. **远期**:鉴权/账户体系重做(从 `deps.py` 层重引);ISS-only 产品模式构建;`extension-v1/v2`、`web/`、`server/legacy/` 清理时机。
|
||||
|
||||
## 8. 已知坑(新会话必读)
|
||||
|
||||
| # | 坑 | 对策 |
|
||||
|---|---|---|
|
||||
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 server |
|
||||
| 2 | 套图任务在内存(`services/tasks.py`) | server 重启丢任务状态(前端已提示重新生成);已落盘图片不丢 |
|
||||
| 2 | 套图任务在内存(`services/tasks.py`) | server 重启丢任务状态(前端已提示重新生成);已落盘图片不丢(在「生图记录」区) |
|
||||
| 3 | 生图全局串行(`generator.py` 的 `asyncio.Lock`) | 一次只跑一个生成队列,测试时别并发提交 |
|
||||
| 4 | 单张 image-edit 是同步接口 | gpt 系列单张 1-5 分钟,前端 axios 超时 120s 可能先断(后端仍在跑,图会出现在生成结果) |
|
||||
| 5 | **ZCode IAB 浏览器调试 quirks**:`press`/Backspace 键注入在该页时灵时不灵(`fill` 可靠);`fullPage` 截图偶发失败;文件上传不可用;改代码后页面 React 事件偶发失灵 → **reload 页面**即恢复 | 优先用 `fill`/`domSnapshot`/`getBoundingClientRect` 的 evaluate(注意 evaluate 可能被拒 side-effect,读值即可) |
|
||||
| 6 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
|
||||
| 7 | `studio/tsconfig.app.tsbuildinfo` 是构建产物 | 提交前 `git checkout -- studio/tsconfig.app.tsbuildinfo` |
|
||||
| 8 | 冻结代码不要投入 | `server/legacy/`、`extension-v1/`、`extension-v2/`、`web/`、studio 商品编辑页的属性映射/发布区块 |
|
||||
| 9 | AI 规划/生图花钱 | rightapi 按张计费;联调时 plan 张数设 1-2、优先 `nano-banana-2-lite`(快/便宜) |
|
||||
| 4 | 单张 image-edit 是同步接口 | 慢模型单张 1-5 分钟,前端 axios 超时 120s 可能先断(后端仍在跑,图会出现在生图记录) |
|
||||
| 5 | **axios 实例勿设全局 Content-Type** | axios 1.x 遇「默认 JSON 头 + FormData」会把表单转 JSON 发送(`formDataToJSON`),multipart 接口全 422。已从 `api.ts` 移除;对象请求体 axios 会自动补 JSON 头 |
|
||||
| 6 | `web/` 样式依赖公网 CDN | tailwind 已固化 `web/vendor/tailwind.js`;font-awesome/jsdelivr 若也断,同样方式下载到 `web/vendor/` 改引用 |
|
||||
| 7 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
|
||||
| 8 | `studio/tsconfig.app.tsbuildinfo` 是构建产物 | 提交前 `git checkout -- studio/tsconfig.app.tsbuildinfo` |
|
||||
| 9 | 冻结代码不要投入 | `server/legacy/`、`extension-v1/`、`extension-v2/`、`web/`(本轮 CDN 固化属修复例外)、studio 商品编辑页的属性映射/发布区块 |
|
||||
| 10 | AI 规划/生图花钱 | pcore 按 $ 扣费、RightCode 按张计费;联调时 plan 张数设 1-2、优先低成本模型 |
|
||||
| 11 | TrialPage `load()` 勿再置 loading | 置 loading = 整页 Spin + 卸载面板 = 杀生图轮询/丢状态(已修,保持静默刷新) |
|
||||
| 12 | 浏览器自动化调试怪癖(如 ZCode IAB) | 截图偶发超时(重试一次多半仍失败,改用几何测量/计算样式验证);文件上传需用 DataTransfer+input change 事件模拟;改代码后 React 事件偶发失灵 → reload 页面即恢复 |
|
||||
|
||||
## 6. 关键文件速查
|
||||
## 9. 关键文件速查
|
||||
|
||||
| 功能 | 前端(studio/src/) | 后端(server/) |
|
||||
|---|---|---|
|
||||
| 试算页骨架 | `pages/trial/TrialPage.tsx` | — |
|
||||
| 01 商品信息(型号/货号联动、采买地址) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py`(上报/删素材) |
|
||||
| 试算页骨架 | `pages/trial/TrialPage.tsx`(load() 是静默刷新,见坑 11) | — |
|
||||
| 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` |
|
||||
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
|
||||
| 03 俄文文案 | `pages/product/CopyPanel.tsx`(共用) | `api/ai.py` + `services/deepseek.py` + `prompts/copy_ru.py` |
|
||||
| 04 图片与生图 | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{suite_service,generator,planner,prompts/,tasks,watermark}.py` |
|
||||
| 04 图片与生图(8 列网格/悬浮按钮/图名) | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{suite_service,generator,planner,prompts/,tasks,watermark}.py` |
|
||||
| 生图供应商路由 | `services/suite.ts`(IMAGE_MODEL_OPTIONS 带供应商名;DEFAULT_IMAGE_MODEL) | `schemas/suite.py`(MODEL_PROVIDERS/`@pcore` 约定)+ `services/generator.py`(generate_pcore 等) |
|
||||
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — |
|
||||
| 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` |
|
||||
| 采集箱(全选/批量删除) | `pages/collection/CollectionPage.tsx` | `api/products.py`(DELETE hard=true) |
|
||||
| 前端服务层 | `services/{suite,product,ai,fx,api}.ts` | `schemas/suite.py`(契约对齐) |
|
||||
| 扩展上报 | `extensions/collector/src/api/report.ts` + `entrypoints/{background,sidepanel/App}.tsx` + `storage/settings.ts` | `api/materials.py` |
|
||||
|
||||
## 7. 验收标准(改完怎么算完成)
|
||||
## 10. 验收标准(改完怎么算完成)
|
||||
|
||||
1. `pnpm -C studio exec tsc -b` 零错误、`pnpm -C studio build` 通过(提交前还原 `tsconfig.app.tsbuildinfo`)
|
||||
2. 改扩展:`pnpm -C extensions/collector build` 通过
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
```
|
||||
**Res** `{ "summary": "一句话方案说明", "items": [ { "kind": "white_bg", "title": "白底主图", "detail": "…", "prompt_hint": "…", "count": 2, "variant_name": "粉色|null" } ] }`
|
||||
|
||||
实现要点:DeepSeek 单行紧凑 JSON;kind 白名单清洗;count 钳 0-3;幻觉 variant 丢弃绑定;总数建议 8-15。同步接口(数秒级)。
|
||||
实现要点:DeepSeek 单行紧凑 JSON;kind 白名单清洗;count 钳 0-3;幻觉 variant 丢弃绑定;总张数 AI 自主(建议 8 张左右;安全上限:默认 15,生图要求指定「N 张」时按 N、最大 24)。同步接口(数秒级)。
|
||||
|
||||
## 3. 一键生成 `POST /api/suite/generate`(Phase B)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
- **类型白名单(10 种)**:white_bg 白底主图 / key_features 核心卖点图 / selling_pt 卖点图 / material 材质图 / lifestyle 场景展示图 / multi_scene 多场景拼图 / ecommerce_detail 电商详情图 / size_chart 尺寸标注图 / sku_collection SKU合集 / custom 创意图。
|
||||
- **默认方案**:前 7 种各 1 张;行点击切换 0↔1,± 调 0-5,「全部方案」批量开关。
|
||||
- **AI 规划**:请求 `{product_id, texts, sku_variants, image_stats, platform, requirements}`;DeepSeek 输出单行 JSON `{summary, items:[{kind,title,detail,prompt_hint,count,variant_name}]}`;服务端清洗(kind 白名单、count 钳 0-3、幻觉 SKU 丢弃绑定、截断修复)。规则:每个带图 SKU 1 张 white_bg 且绑定 variant_name;总数 8-15。
|
||||
- **AI 规划**:请求 `{product_id, texts, sku_variants, image_stats, platform, requirements}`;DeepSeek 输出单行 JSON `{summary, items:[{kind,title,detail,prompt_hint,count,variant_name}]}`;服务端清洗(kind 白名单、count 钳 0-3、幻觉 SKU 丢弃绑定、截断修复)。规则:每个带图 SKU 1 张 white_bg 且绑定 variant_name。**总张数由 AI 自主决定(不设硬性数量,建议 8 张左右)**;「生图要求」写「N 张」可指定张数(AI 必须执行);服务端只设防跑飞安全上限(默认 15、指定时按 N 最大 24),超限从尾部截断(2026-08-29)。
|
||||
- **规划并生成**:勾选后规划完成直接进入生成。
|
||||
- SKU 规格来源:`product_assets` 中 `group_key='sku'` 的 `variant_name` 去重。
|
||||
|
||||
|
||||
+5
-3
@@ -28,6 +28,7 @@ from schemas.suite import (
|
||||
SuiteGenerateRequest,
|
||||
SuiteOut,
|
||||
SuitePlanRequest,
|
||||
api_model,
|
||||
resolve_provider,
|
||||
)
|
||||
from services.generator import _image_size, GENERATORS, run_suite
|
||||
@@ -98,9 +99,10 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi)
|
||||
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi、gpt-image-2@pcore → pcore)
|
||||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
||||
validate_model(provider_name, req.model)
|
||||
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
||||
|
||||
product_id = (req.product_id or "").strip() or None
|
||||
if product_id:
|
||||
@@ -118,7 +120,7 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
|
||||
style_prompt=req.style_prompt,
|
||||
requirements=req.requirements,
|
||||
provider=provider_name,
|
||||
model=req.model,
|
||||
model=model,
|
||||
total=len(jobs),
|
||||
context=texts_to_raw(req.texts),
|
||||
plan=jobs,
|
||||
@@ -214,7 +216,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
|
||||
validate_model(provider_name, req.model)
|
||||
|
||||
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
|
||||
model = req.model
|
||||
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
||||
is_wan = provider_name == "tongyi" and model.lower().startswith("wan")
|
||||
|
||||
# 复用套图的 prompt 家族:custom 类型装配「用户要求」为最高优先级约束,
|
||||
|
||||
@@ -72,6 +72,12 @@ class Settings(BaseSettings):
|
||||
rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
|
||||
rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
|
||||
|
||||
# Pcore(OpenAI 兼容中转:pcore.ai,异步图片任务;模型值带 @pcore 后缀路由到此)
|
||||
pcore_api_key: str = ""
|
||||
pcore_base_url: str = "https://pcore.ai"
|
||||
pcore_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
|
||||
pcore_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
|
||||
|
||||
# DeepSeek 出图方案规划器(key 复用上方 deepseek_api_key;文案生成的模型走 config/models.yaml,互不影响)
|
||||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||
deepseek_model: str = "deepseek-v4-flash"
|
||||
|
||||
+16
-6
@@ -24,15 +24,25 @@ RIGHTAPI_MODELS = [
|
||||
"nano-banana-pro",
|
||||
]
|
||||
|
||||
# 模型 → provider 推断表:前端只传模型名,服务端据此路由(模型名优先于 provider 字段)
|
||||
MODEL_PROVIDERS: dict[str, str] = {
|
||||
**{m: "tongyi" for m in TONGYI_MODELS},
|
||||
**{m: "rightapi" for m in RIGHTAPI_MODELS},
|
||||
}
|
||||
# Pcore 生图模型白名单(pcore.ai 中转;同一模型名与 RightAPI 重名,用 @pcore 后缀区分路由)
|
||||
PCORE_MODELS = ["gpt-image-2"]
|
||||
|
||||
# 豆包 Seedream 走火山方舟;模型由 ARK_IMAGE_MODEL 指定(不在下拉白名单内也允许直连豆包)
|
||||
DOUBAO_MODELS = ["doubao-seedream-4-5-251128"]
|
||||
MODEL_PROVIDERS.update({m: "doubao" for m in DOUBAO_MODELS})
|
||||
|
||||
# 模型 → provider 推断表:前端只传模型名,服务端据此路由(模型名优先于 provider 字段)
|
||||
# 同名模型走多家中转时,前端传「api_model@provider」形式(如 gpt-image-2@pcore)
|
||||
MODEL_PROVIDERS: dict[str, str] = {
|
||||
**{m: "tongyi" for m in TONGYI_MODELS},
|
||||
**{m: "rightapi" for m in RIGHTAPI_MODELS},
|
||||
**{m: "doubao" for m in DOUBAO_MODELS},
|
||||
**{f"{m}@pcore": "pcore" for m in PCORE_MODELS},
|
||||
}
|
||||
|
||||
|
||||
def api_model(model: str | None) -> str | None:
|
||||
"""前端模型值 → 发给供应商 API 的模型名:'gpt-image-2@pcore' → 'gpt-image-2'。"""
|
||||
return model.split("@", 1)[0] if model and "@" in model else model
|
||||
|
||||
|
||||
def resolve_provider(model: str | None, requested: str | None, default: str) -> str:
|
||||
|
||||
@@ -61,9 +61,11 @@ def _raise_api_error(resp, provider: str):
|
||||
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 = text[:200]
|
||||
raise ApiError(f"{provider} API HTTP {resp.status_code} — {text[:200]}", resp.status_code)
|
||||
# 兼容两种错误形状:顶层 {code, message}(豆包/通义)与嵌套 {error: {code, message}}(new_api 系中转)
|
||||
err = body.get("error") if isinstance(body.get("error"), dict) else body
|
||||
detail = f"{err.get('code', '')}: {err.get('message', '')}".strip(': ')
|
||||
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}", resp.status_code)
|
||||
|
||||
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
|
||||
@@ -80,6 +82,9 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "")
|
||||
if provider == "rightapi":
|
||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
if provider == "pcore":
|
||||
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
|
||||
return "1024x1536" if ratio == "3:4" else "1024x1024"
|
||||
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
||||
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
|
||||
if model.startswith("wan2.6"):
|
||||
@@ -379,7 +384,110 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
|
||||
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi}
|
||||
# ── Provider:Pcore(pcore.ai,OpenAI 兼容异步图片协议)──────────────────
|
||||
|
||||
async def _pcore_poll_task(client: httpx.AsyncClient, headers: dict, base: str,
|
||||
kind: str, task_id: str, max_wait: int) -> dict:
|
||||
"""轮询 Pcore 任务:GET {base}/v1/images/{kind}/{task_id},kind ∈ generations | edits。
|
||||
|
||||
状态机:queued / in_progress → 继续;completed / succeeded → 返回;
|
||||
failed / error / cancelled → 抛错。
|
||||
"""
|
||||
poll_url = f"{base}/v1/images/{kind}/{task_id}"
|
||||
elapsed, interval = 0, 3
|
||||
while elapsed < max_wait:
|
||||
resp = await client.get(poll_url, headers=headers, timeout=30)
|
||||
_raise_api_error(resp, "Pcore")
|
||||
result = resp.json()
|
||||
status = result.get("status", "")
|
||||
if status in ("failed", "error", "cancelled"):
|
||||
err = result.get("error") or {}
|
||||
raise RuntimeError(f"Pcore 任务失败: {err.get('message') or result}")
|
||||
if status in ("completed", "succeeded"):
|
||||
return result
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
interval = min(interval + 2, 10)
|
||||
raise TimeoutError(f"Pcore 异步任务超时 ({max_wait}s): task_id={task_id}")
|
||||
|
||||
|
||||
async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
|
||||
"""Pcore:无参考图走 /v1/images/generations,带参考图走 /v1/images/edits(JSON,data-URI)。
|
||||
|
||||
两者均为异步:提交返回任务 id → 轮询对应资源路径 → 完成后 data[0].url 取图。
|
||||
完成形状与 RightAPI 同为 Images 协议,复用 _rightapi_extract_image。
|
||||
"""
|
||||
base = s.pcore_base_url.rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {s.pcore_api_key}"}
|
||||
|
||||
kind = "edits" if ref_images else "generations"
|
||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||
if ref_images:
|
||||
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
|
||||
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/{kind}",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
_raise_api_error(resp, "Pcore")
|
||||
submitted = resp.json()
|
||||
# new_api 系中转常见「HTTP 200 + 错误体」,需主动检查 error 字段
|
||||
if isinstance(submitted, dict) and submitted.get("error"):
|
||||
err = submitted["error"] or {}
|
||||
raise RuntimeError(f"Pcore 提交失败: {err.get('message') or submitted}")
|
||||
task_id = submitted.get("task_id") or submitted.get("id") or ""
|
||||
if not task_id:
|
||||
raise RuntimeError(f"Pcore 提交未返回任务 id: {submitted}")
|
||||
result = await _pcore_poll_task(client, headers, base, kind, task_id, s.poll_max_wait)
|
||||
|
||||
img_kind, payload = _rightapi_extract_image(result)
|
||||
if img_kind == "b64" and payload:
|
||||
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
|
||||
if img_kind == "url" and payload:
|
||||
dl = await client.get(payload, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
raise RuntimeError(f"Pcore 任务完成但没有图片数据: {result}")
|
||||
|
||||
|
||||
async def generate_pcore(prompt: str, ref_images: list[str], size: str = "1536x2048", model: str | None = None) -> bytes:
|
||||
"""带重试的 Pcore 入口:429/5xx/超时按递增间隔重试(与 RightAPI 同策略)。"""
|
||||
s = get_settings()
|
||||
if not s.pcore_api_key:
|
||||
raise RuntimeError("未配置 PCORE_API_KEY(.env)")
|
||||
model = model or "gpt-image-2"
|
||||
attempts = max(1, s.pcore_max_retries)
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return await _pcore_request(s, prompt, ref_images, size, model)
|
||||
except ApiError as exc:
|
||||
last_exc = exc
|
||||
if exc.status not in RETRYABLE_STATUS:
|
||||
raise
|
||||
# 通道不存在(模型未开通)重试无意义,立即失败
|
||||
if "no available channel" in str(exc).lower() or "model_not_found" in str(exc).lower():
|
||||
raise
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
last_exc = exc # 网络抖动/超时可重试
|
||||
if i == attempts - 1:
|
||||
break
|
||||
wait = s.pcore_retry_wait * (2 ** i)
|
||||
log.warning("Pcore 第 %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,
|
||||
"pcore": generate_pcore,
|
||||
}
|
||||
|
||||
|
||||
# ── 任务执行器 ────────────────────────────────────────────────────────────
|
||||
|
||||
+47
-19
@@ -22,23 +22,39 @@ ALLOWED_KINDS = [
|
||||
"size_chart", "sku_collection", "custom",
|
||||
]
|
||||
|
||||
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
|
||||
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
|
||||
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
|
||||
PLAN_SAFETY_MAX = 15
|
||||
PLAN_REQUIREMENTS_MAX = 24
|
||||
|
||||
|
||||
def _total_budget(requirements: str | None) -> int:
|
||||
"""从生图要求提取「N 张」作为总张数预算;未指定时用默认安全上限。"""
|
||||
if requirements:
|
||||
m = re.search(r"(\d+)\s*张", requirements)
|
||||
if m:
|
||||
return min(max(int(m.group(1)), 1), PLAN_REQUIREMENTS_MAX)
|
||||
return PLAN_SAFETY_MAX
|
||||
|
||||
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商套图的出图方案。
|
||||
|
||||
## 输出硬性约束(违反即失败)
|
||||
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
||||
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
||||
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
|
||||
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数 8-15。
|
||||
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
|
||||
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
||||
|
||||
## 规划规则
|
||||
1. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
|
||||
2. 场景图(kind=lifestyle):按核心使用场景出 2-4 张,每张聚焦一个场景。
|
||||
3. 细节图(kind=material 或 custom):按关键细节/材质/结构出 2-3 张,每张聚焦一个卖点。
|
||||
4. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
|
||||
5. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
|
||||
6. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
|
||||
7. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
||||
1. 先保证 SKU 主图(规则 2),其余类型(场景/细节/尺寸/合集等)按商品特点自行取舍并分配张数,追求信息效率,不为凑数出图。
|
||||
2. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
|
||||
3. 场景图(kind=lifestyle):按核心使用场景出 1-3 张,每张聚焦一个场景。
|
||||
4. 细节图(kind=material 或 custom):按关键细节/材质/结构出 1-2 张,每张聚焦一个卖点。
|
||||
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时出 1 张。
|
||||
6. SKU 合集图(kind=sku_collection):SKU >1 时出 1 张。
|
||||
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
|
||||
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
||||
|
||||
## 输出示例(紧凑单行)
|
||||
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
|
||||
@@ -78,9 +94,8 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
kind = str(it.get("kind") or "custom")
|
||||
if kind not in ALLOWED_KINDS:
|
||||
kind = "custom"
|
||||
title = str(it.get("title") or "").strip()[:20]
|
||||
if not title:
|
||||
continue
|
||||
# title 缺失不再丢弃条目:从 detail / kind 兜底合成,避免整份方案被判空
|
||||
title = str(it.get("title") or "").strip()[:20] or str(it.get("detail") or "").strip()[:12] or kind
|
||||
try:
|
||||
count = max(0, min(3, int(it.get("count", 1))))
|
||||
except (TypeError, ValueError):
|
||||
@@ -173,6 +188,8 @@ async def generate_plan(
|
||||
user_payload["生图要求(最高优先级,必须体现在每个方案项中)"] = requirements.strip()
|
||||
user_content = json.dumps(user_payload, ensure_ascii=False)
|
||||
|
||||
last_error: RuntimeError | ValueError | None = None
|
||||
for attempt in range(2): # 解析失败/空方案自动重试一次
|
||||
async with httpx.AsyncClient(timeout=90, verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{s.deepseek_base_url.rstrip('/')}/chat/completions",
|
||||
@@ -186,6 +203,9 @@ async def generate_plan(
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 8000,
|
||||
# deepseek-v4 是思维链模型:不禁用 thinking 会把 max_tokens 烧在推理上,
|
||||
# 正文为空 → 只能拿 reasoning_content 兜底解析 → 频发「解析失败/未返回有效方案项」
|
||||
"thinking": {"type": "disabled"},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -205,23 +225,31 @@ async def generate_plan(
|
||||
try:
|
||||
data = _extract_json(content)
|
||||
except ValueError as exc:
|
||||
last_error = exc
|
||||
log.error(
|
||||
"规划器输出解析失败 finish_reason=%s content[:200]=%s",
|
||||
finish_reason, content[:200],
|
||||
"规划器第 %d 次输出解析失败 finish_reason=%s content[:200]=%s",
|
||||
attempt + 1, finish_reason, content[:200],
|
||||
)
|
||||
raise RuntimeError("规划器输出解析失败") from exc
|
||||
continue
|
||||
|
||||
items = _normalize_items(data.get("items") or [], sku_variants)
|
||||
if not items:
|
||||
raise RuntimeError("规划器未返回有效方案项")
|
||||
# 总量保护:超过 18 张时按比例截断
|
||||
if items:
|
||||
# 总量保护:只防跑飞不干预正常规划。生图要求指定「N 张」时按 N 放宽上限
|
||||
budget = _total_budget(requirements)
|
||||
total = sum(i["count"] for i in items)
|
||||
while total > 18 and items:
|
||||
while total > budget and items:
|
||||
last = items[-1]
|
||||
if last["count"] > 1:
|
||||
last["count"] -= 1
|
||||
else:
|
||||
items.pop()
|
||||
total = sum(i["count"] for i in items)
|
||||
|
||||
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
|
||||
|
||||
last_error = RuntimeError("规划器未返回有效方案项")
|
||||
log.warning(
|
||||
"规划器第 %d 次返回无有效方案项 finish_reason=%s content[:200]=%s",
|
||||
attempt + 1, finish_reason, content[:200],
|
||||
)
|
||||
|
||||
raise last_error or RuntimeError("规划器未返回有效方案项")
|
||||
|
||||
@@ -25,7 +25,7 @@ _MODULE_BY_FAMILY = {
|
||||
|
||||
def prompt_family(provider: str, model: str | None) -> str:
|
||||
"""(provider, model) → 提示词家族名。"""
|
||||
if provider == "rightapi":
|
||||
if provider in ("rightapi", "pcore"):
|
||||
if (model or "").lower().startswith("nano-banana"):
|
||||
return "google"
|
||||
return "gpt" # gpt-image-* 及未知中转模型默认按 edits 语义处理
|
||||
|
||||
@@ -38,12 +38,15 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
|
||||
|
||||
def validate_model(provider_name: str, model: str | None) -> None:
|
||||
"""按 provider 校验模型名白名单,不合法抛 ValueError。"""
|
||||
from schemas.suite import RIGHTAPI_MODELS, TONGYI_MODELS
|
||||
from schemas.suite import PCORE_MODELS, RIGHTAPI_MODELS, TONGYI_MODELS, api_model
|
||||
|
||||
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
|
||||
raise ValueError(f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})")
|
||||
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
|
||||
raise ValueError(f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||
m = api_model(model) # 'gpt-image-2@pcore' → 'gpt-image-2'
|
||||
if provider_name == "tongyi" and m and m not in TONGYI_MODELS:
|
||||
raise ValueError(f"不支持的模型: {m}(tongyi 支持: {TONGYI_MODELS})")
|
||||
if provider_name == "rightapi" and m and m not in RIGHTAPI_MODELS:
|
||||
raise ValueError(f"不支持的模型: {m}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||
if provider_name == "pcore" and m and m not in PCORE_MODELS:
|
||||
raise ValueError(f"不支持的模型: {m}(pcore 支持: {PCORE_MODELS})")
|
||||
|
||||
|
||||
async def append_generated_asset(
|
||||
|
||||
+45
-2
@@ -24,17 +24,60 @@ if [[ ! -f .env ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
|
||||
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
|
||||
COLLECTOR_WATCH_PID=""
|
||||
COLLECTOR_STAMP="$(mktemp -u /tmp/osk-collector-stamp.XXXXXX)"
|
||||
watch_collector() {
|
||||
touch "$COLLECTOR_STAMP"
|
||||
while true; do
|
||||
sleep 3
|
||||
if [[ -n "$(find extensions/collector/src extensions/collector/wxt.config.ts -newer "$COLLECTOR_STAMP" -print 2>/dev/null)" ]]; then
|
||||
touch "$COLLECTOR_STAMP"
|
||||
echo "🔧 检测到插件代码改动,重新构建…"
|
||||
if pnpm -C extensions/collector build >/dev/null 2>&1; then
|
||||
echo "✅ 采集插件已重建($(date +%H:%M:%S)),去 chrome://extensions 点刷新生效"
|
||||
else
|
||||
echo "⚠️ 采集插件重建失败($(date +%H:%M:%S)):可手动跑 pnpm -C extensions/collector build 看报错"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
if [[ ! -d extensions/collector ]]; then
|
||||
echo "⚠️ 未找到 extensions/collector,跳过采集插件构建"
|
||||
elif ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "⚠️ 未找到 pnpm,本次跳过采集插件构建(可手动:pnpm -C extensions/collector build)"
|
||||
elif ! CI=true pnpm -C extensions/collector install --silent; then
|
||||
echo "⚠️ 采集插件依赖安装失败,本次跳过构建"
|
||||
else
|
||||
echo "正在构建采集插件…"
|
||||
if pnpm -C extensions/collector build; then
|
||||
echo "采集插件已构建:extensions/collector/.output/chrome-mv3"
|
||||
else
|
||||
echo "⚠️ 采集插件构建失败,继续启动服务(可手动重试:pnpm -C extensions/collector build)"
|
||||
fi
|
||||
watch_collector &
|
||||
COLLECTOR_WATCH_PID=$!
|
||||
fi
|
||||
echo
|
||||
|
||||
# ---- 同步并启动前端(studio:Vite 开发服务器,/api 代理到后端 8800)----
|
||||
STUDIO_PID=""
|
||||
stop_studio() {
|
||||
cleanup() {
|
||||
if [[ -n "$STUDIO_PID" ]] && kill -0 "$STUDIO_PID" 2>/dev/null; then
|
||||
kill "$STUDIO_PID" 2>/dev/null
|
||||
sleep 0.2
|
||||
# 兜底:确保 vite 及其子进程退出
|
||||
lsof -ti :8900 | xargs kill 2>/dev/null
|
||||
fi
|
||||
if [[ -n "$COLLECTOR_WATCH_PID" ]] && kill -0 "$COLLECTOR_WATCH_PID" 2>/dev/null; then
|
||||
pkill -P "$COLLECTOR_WATCH_PID" 2>/dev/null # 可能正在跑的 build 子进程
|
||||
kill "$COLLECTOR_WATCH_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$COLLECTOR_STAMP" 2>/dev/null
|
||||
}
|
||||
trap stop_studio EXIT
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type Key } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Card, Input, message, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -43,6 +43,7 @@ export default function CollectionPage() {
|
||||
const res = await listProducts({ stage, q: q || undefined, page, page_size: pageSize });
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setSelectedKeys([]);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
@@ -76,6 +77,26 @@ export default function CollectionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 勾选与当前列表视图绑定:每次 load 后清空,避免残留不可见商品的勾选
|
||||
const [selectedKeys, setSelectedKeys] = useState<Key[]>([]);
|
||||
const allSelected = items.length > 0 && selectedKeys.length === items.length;
|
||||
const toggleSelectAll = () => {
|
||||
setSelectedKeys(allSelected ? [] : items.map((i) => i.id));
|
||||
};
|
||||
|
||||
const onBatchDelete = async () => {
|
||||
const ids = [...selectedKeys];
|
||||
const results = await Promise.allSettled(ids.map((id) => deleteProduct(String(id), true)));
|
||||
const ok = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const fail = results.length - ok;
|
||||
if (fail > 0) message.warning(`已删除 ${ok} 个商品,${fail} 个失败`);
|
||||
else message.success(`已删除 ${ok} 个商品`);
|
||||
setSelectedKeys([]);
|
||||
// 当前页删空时回退一页,避免停在空页
|
||||
if (ok > 0 && ok === items.length && page > 1) setPage(page - 1);
|
||||
else load();
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ProductListItem> = [
|
||||
{
|
||||
title: '商品名',
|
||||
@@ -140,7 +161,17 @@ export default function CollectionPage() {
|
||||
<Title level={4} style={{ marginTop: 0 }}>
|
||||
采集箱
|
||||
</Title>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索名称 / 货号"
|
||||
allowClear
|
||||
@@ -167,11 +198,30 @@ export default function CollectionPage() {
|
||||
</select>
|
||||
<Button onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button onClick={toggleSelectAll} disabled={items.length === 0}>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedKeys.length} 个商品?`}
|
||||
disabled={selectedKeys.length === 0}
|
||||
onConfirm={onBatchDelete}
|
||||
>
|
||||
<Button danger disabled={selectedKeys.length === 0}>
|
||||
删除{selectedKeys.length > 0 ? `(${selectedKeys.length})` : ''}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: (keys) => setSelectedKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Col, Input, message, Row, Typography } from 'antd';
|
||||
import { Col, Input, message, Row, Typography } from 'antd';
|
||||
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { copyText } from '@/utils/file';
|
||||
@@ -137,15 +137,9 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
|
||||
addonAfter={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
{openUrl && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => window.open(openUrl, '_blank', 'noopener')}
|
||||
>
|
||||
打开地址
|
||||
</Button>
|
||||
<a title="打开采买地址" onClick={() => window.open(openUrl, '_blank', 'noopener')}>
|
||||
<ExportOutlined />
|
||||
</a>
|
||||
)}
|
||||
<a title="复制采买地址" onClick={copyPurchaseUrl}>
|
||||
<CopyOutlined />
|
||||
|
||||
@@ -31,21 +31,20 @@ export default function TrialPage() {
|
||||
const { id } = useParams();
|
||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||
const [assets, setAssets] = useState<ProductAsset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [active, setActive] = useState(SECTIONS[0].id);
|
||||
|
||||
// 静默刷新:不置 loading。置 loading 会让整页退回全屏 Spin、卸载 TrialSuitePanel,
|
||||
// 杀掉生图轮询并丢失勾选/方案状态(表现为「生成中看不到图、上传后页面刷新」)。
|
||||
// 首屏的加载态由 product 为空时的 Spin 兜底。
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [p, a] = await Promise.all([getProduct(id), listAssets(id)]);
|
||||
setProduct(p);
|
||||
setAssets(a);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
@@ -83,7 +82,7 @@ export default function TrialPage() {
|
||||
[id, product],
|
||||
);
|
||||
|
||||
if (loading || !product) {
|
||||
if (!product) {
|
||||
return (
|
||||
<div style={{ padding: 80, textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popconfirm, Popover, Progress,
|
||||
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, Popconfirm, Popover, Progress,
|
||||
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
@@ -145,10 +145,10 @@ function loadWatermark(): WatermarkPayload {
|
||||
}
|
||||
|
||||
export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Props) {
|
||||
// 素材选择(默认全选主图 + SKU 图,SKU 规格名供 AI 规划 variant 绑定)
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
|
||||
() => new Set(assets.filter((a) => ['main', 'sku'].includes(a.group_key) && a.type !== 'video').map((a) => a.id)),
|
||||
);
|
||||
// message / modal 用 App 上下文版本:静态方法吃不到 ConfigProvider 动态主题(控制台告警)
|
||||
const { modal, message } = AntdApp.useApp();
|
||||
// 素材选择(默认全部不勾选,用户按需勾选参考底图;SKU 规格名供 AI 规划 variant 绑定,与勾选无关)
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(() => new Set());
|
||||
/** 上传成功待自动勾选的 asset id */
|
||||
const pendingSelectRef = useRef<Set<string>>(new Set());
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
@@ -169,6 +169,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
const [suite, setSuite] = useState<SuiteInfo | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [exportingZip, setExportingZip] = useState(false);
|
||||
const [exportingResult, setExportingResult] = useState(false);
|
||||
const [exportingImages, setExportingImages] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
/** 规划请求序号:重置后丢弃迟到的过期响应 */
|
||||
@@ -182,6 +183,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
const generatedAssets = useMemo(() => assets.filter((a) => a.group_key === 'generated'), [assets]);
|
||||
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
|
||||
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
|
||||
// 图名:取 URL 路径末段(采集图为源站文件名;上传图为落盘文件名),无路径时回退规格名
|
||||
const assetName = (a: ProductAsset): string => {
|
||||
const raw = a.source_url || a.stored_url || '';
|
||||
try {
|
||||
const seg = decodeURIComponent(new URL(raw, window.location.origin).pathname.split('/').pop() || '');
|
||||
return seg || a.variant_name || '未命名';
|
||||
} catch {
|
||||
return a.variant_name || '未命名';
|
||||
}
|
||||
};
|
||||
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
|
||||
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
|
||||
|
||||
@@ -402,7 +413,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
message.warning('出图方案的张数都是 0');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '生成电商套图',
|
||||
content: (
|
||||
<div>
|
||||
@@ -432,6 +443,38 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
}
|
||||
};
|
||||
|
||||
// ── 导出全部生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
|
||||
const handleExportResult = async () => {
|
||||
const suiteOk = (suite?.images ?? []).filter((i) => i.status === 'ok');
|
||||
if (suiteOk.length === 0 && generatedAssets.length === 0) {
|
||||
message.warning('暂无生图结果可导出');
|
||||
return;
|
||||
}
|
||||
setExportingResult(true);
|
||||
try {
|
||||
const images = [
|
||||
...suiteOk.map((img, i) => ({
|
||||
url: img.url,
|
||||
groupName: '套图结果',
|
||||
variantName: null,
|
||||
key: `suite-${String(i + 1).padStart(3, '0')}`,
|
||||
})),
|
||||
...generatedAssets.map((a, i) => ({
|
||||
url: assetUrl(a),
|
||||
groupName: '单张生图',
|
||||
variantName: a.variant_name,
|
||||
key: `single-${String(i + 1).padStart(3, '0')}`,
|
||||
})),
|
||||
];
|
||||
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images });
|
||||
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setExportingResult(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setPlanCount = (idx: number, count: number) => {
|
||||
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p)));
|
||||
};
|
||||
@@ -571,35 +614,34 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, minmax(0, 1fr))', gap: 10 }}>
|
||||
{groupImages(g).map((a) => {
|
||||
const on = selectedKeys.has(a.id);
|
||||
return (
|
||||
<div key={a.id} style={{ width: 112, position: 'relative' }}>
|
||||
<div key={a.id}>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
background: on ? 'rgba(22,119,255,0.06)' : undefined,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(a)}
|
||||
width={100}
|
||||
height={100}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
width="100%"
|
||||
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
|
||||
wrapperStyle={{ width: '100%', display: 'block' }}
|
||||
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
title="勾选为参考底图"
|
||||
onClick={() => toggleKey(a.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 6,
|
||||
right: 6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
@@ -611,37 +653,51 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
zIndex: 1,
|
||||
zIndex: 2,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{a.variant_name && (
|
||||
<Tag
|
||||
style={{ position: 'absolute', top: 6, right: 6, margin: 0, zIndex: 1 }}
|
||||
>
|
||||
{a.variant_name}
|
||||
</Tag>
|
||||
)}
|
||||
{a.status !== 'uploaded' && (
|
||||
<Tag
|
||||
color={a.status === 'failed' ? 'red' : 'default'}
|
||||
style={{ position: 'absolute', bottom: 40, left: 6, margin: 0, zIndex: 1 }}
|
||||
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}
|
||||
>
|
||||
{a.status === 'failed' ? '转存失败' : '转存中'}
|
||||
</Tag>
|
||||
)}
|
||||
{/* AI 生图:半透明悬浮在图片右下角 */}
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
style={{ marginTop: 4 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
zIndex: 2,
|
||||
background: 'rgba(0, 0, 0, 0.45)',
|
||||
color: '#fff',
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
AI 生图
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
title={a.variant_name || assetName(a)}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
marginTop: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{a.variant_name || assetName(a)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -854,6 +910,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
水印
|
||||
</Button>
|
||||
</Popover>
|
||||
{/* 导出全部生图结果(套图 + 单张生图) */}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={exportingResult}
|
||||
disabled={(!suite || !['done', 'partial'].includes(suite.status)) && generatedAssets.length === 0}
|
||||
onClick={handleExportResult}
|
||||
>
|
||||
导出图片
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -950,9 +1016,9 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
{generatedAssets.length > 0 && (
|
||||
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
|
||||
<Text strong>
|
||||
单张 AI 生图
|
||||
生图记录
|
||||
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
|
||||
(采集图片区「AI 生图」的结果 · {generatedAssets.length} 张)
|
||||
(一键生成与单张生图的落库历史,刷新后仍保留 · {generatedAssets.length} 张)
|
||||
</Text>
|
||||
</Text>
|
||||
<Image.PreviewGroup>
|
||||
|
||||
@@ -5,9 +5,8 @@ import { envConfig } from '@/config/env';
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
timeout: 120000, // 图生图较慢,放宽到 120s
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
|
||||
// 导致 /api/materials/bytes 等表单接口 422(全部字段 missing)。对象请求体 axios 会自动补 JSON 头。
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
|
||||
@@ -54,21 +54,22 @@ export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label
|
||||
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
|
||||
};
|
||||
|
||||
/** 生图模型(服务端按模型名路由 provider,同 image-suite-studio) */
|
||||
/** 生图模型(服务端按模型名路由 provider;同名模型多家中转时 value 用「模型@供应商」区分,label 带供应商名) */
|
||||
export const IMAGE_MODEL_OPTIONS = [
|
||||
{ value: 'qwen-image-3.0-pro', label: 'qwen-image-3.0-pro', desc: '同步生成,响应快、图文理解强,适合快速批量出图' },
|
||||
{ value: 'wan2.7-image-pro', 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', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
|
||||
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
|
||||
{ value: 'nano-banana', label: 'nano-banana', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
|
||||
{ value: 'nano-banana-2', label: 'nano-banana-2', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
|
||||
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
|
||||
{ value: 'nano-banana-pro', label: 'nano-banana-pro', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
|
||||
{ value: 'qwen-image-3.0-pro', label: 'qwen-image-3.0-pro(阿里巴巴)', desc: '同步生成,响应快、图文理解强,适合快速批量出图' },
|
||||
{ value: 'wan2.7-image-pro', 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(RightCode)', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
|
||||
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip(RightCode)', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
|
||||
{ value: 'nano-banana', label: 'nano-banana(RightCode)', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
|
||||
{ value: 'nano-banana-2', label: 'nano-banana-2(RightCode)', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
|
||||
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite(RightCode)', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
|
||||
{ value: 'nano-banana-pro', label: 'nano-banana-pro(RightCode)', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
|
||||
{ value: 'gpt-image-2@pcore', label: 'gpt-image-2(Pcore)', desc: 'Pcore 中转的 GPT 图像模型,异步任务出图,构图与图内文案渲染强' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2-vip';
|
||||
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2@pcore';
|
||||
|
||||
export interface WatermarkPayload {
|
||||
enabled: boolean;
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>轻量版</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- 本地固化:cdn.tailwindcss.com 在当前网络下浏览器侧时通时断,断时整页无样式(2026-08-29) -->
|
||||
<script src="vendor/tailwind.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<script src="js/tailwind.config.js"></script>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
|
||||
Vendored
+83
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user