فهرست منبع

feat(fmode-image-set): sync v0.4.0

Yi Jiarui 1 ماه پیش
والد
کامیت
3efeed6443
46فایلهای تغییر یافته به همراه3458 افزوده شده و 319 حذف شده
  1. 7 7
      claude-code/fmode-image-set/.claude-plugin/plugin.json
  2. 27 17
      claude-code/fmode-image-set/README.md
  3. 1 1
      claude-code/fmode-image-set/bin/fmode-image-set.js
  4. 8 2
      claude-code/fmode-image-set/docs/capability-map.md
  5. 4 2
      claude-code/fmode-image-set/docs/cost-model.md
  6. 10 5
      claude-code/fmode-image-set/docs/customer-quickstart.md
  7. 6 5
      claude-code/fmode-image-set/docs/deliverables/image-set-report.md
  8. 10 4
      claude-code/fmode-image-set/docs/directory-layout.md
  9. 25 8
      claude-code/fmode-image-set/docs/live-manual-acceptance-checklist.md
  10. 4 4
      claude-code/fmode-image-set/docs/troubleshooting.md
  11. 170 0
      claude-code/fmode-image-set/mcp/catalog/domestic-platforms.json
  12. 16 4
      claude-code/fmode-image-set/mcp/src/core/attachment-resolver.mjs
  13. 124 3
      claude-code/fmode-image-set/mcp/src/core/core.test.mjs
  14. 48 0
      claude-code/fmode-image-set/mcp/src/core/public-view.mjs
  15. 3 1
      claude-code/fmode-image-set/mcp/src/core/result-envelope.mjs
  16. 56 10
      claude-code/fmode-image-set/mcp/src/core/run-paths.mjs
  17. 26 3
      claude-code/fmode-image-set/mcp/src/core/state-store.mjs
  18. 1052 79
      claude-code/fmode-image-set/mcp/src/features/generation/orchestrator.mjs
  19. 1047 16
      claude-code/fmode-image-set/mcp/src/features/generation/orchestrator.test.mjs
  20. 131 0
      claude-code/fmode-image-set/mcp/src/features/generation/platform-validation.mjs
  21. 110 0
      claude-code/fmode-image-set/mcp/src/features/generation/platform-validation.test.mjs
  22. 4 6
      claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.mjs
  23. 2 1
      claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.test.mjs
  24. 119 0
      claude-code/fmode-image-set/mcp/src/features/planning/domestic-platforms.mjs
  25. 58 0
      claude-code/fmode-image-set/mcp/src/features/planning/domestic-platforms.test.mjs
  26. 81 5
      claude-code/fmode-image-set/mcp/src/features/planning/planner.mjs
  27. 61 1
      claude-code/fmode-image-set/mcp/src/features/planning/planner.test.mjs
  28. 32 15
      claude-code/fmode-image-set/mcp/src/providers/new-api-image.mjs
  29. 48 8
      claude-code/fmode-image-set/mcp/src/providers/providers.test.mjs
  30. 15 12
      claude-code/fmode-image-set/mcp/src/server.mjs
  31. 5 2
      claude-code/fmode-image-set/mcp/src/tools/edit.mjs
  32. 5 2
      claude-code/fmode-image-set/mcp/src/tools/generate.mjs
  33. 6 5
      claude-code/fmode-image-set/mcp/src/tools/plan.mjs
  34. 2 2
      claude-code/fmode-image-set/package-lock.json
  35. 1 1
      claude-code/fmode-image-set/package.json
  36. 1 1
      claude-code/fmode-image-set/scripts/installer-acceptance.mjs
  37. 46 46
      claude-code/fmode-image-set/skill-package-manifest.json
  38. 1 1
      claude-code/fmode-image-set/skills/fmode-ad-creative/SKILL.md
  39. 4 2
      claude-code/fmode-image-set/skills/fmode-detail-page/SKILL.md
  40. 31 21
      claude-code/fmode-image-set/skills/fmode-image-set/SKILL.md
  41. 20 2
      claude-code/fmode-image-set/skills/fmode-image-set/references/output-format.md
  42. 17 4
      claude-code/fmode-image-set/skills/fmode-image-set/references/workflow.md
  43. 1 1
      claude-code/fmode-image-set/skills/fmode-multi-angle/SKILL.md
  44. 9 6
      claude-code/fmode-image-set/skills/fmode-product-main-image/SKILL.md
  45. 3 3
      claude-code/fmode-image-set/skills/fmode-product-vision/SKILL.md
  46. 1 1
      claude-code/fmode-image-set/skills/fmode-social-visual/SKILL.md

+ 7 - 7
claude-code/fmode-image-set/.claude-plugin/plugin.json

@@ -1,8 +1,8 @@
-{
-  "name": "fmode-image-set",
-  "description": "Plan and generate consistent e-commerce image sets from product facts, references, brand rules, and platform constraints.",
-  "version": "0.2.1",
-  "author": {
-    "name": "Fmode"
-  }
+{
+    "name":  "fmode-image-set",
+    "description":  "Plan and generate consistent e-commerce image sets from product facts, references, brand rules, and platform constraints.",
+    "version":  "0.4.0",
+    "author":  {
+                   "name":  "Fmode"
+               }
 }

+ 27 - 17
claude-code/fmode-image-set/README.md

@@ -5,11 +5,15 @@
 ## 能力
 
 - 商品可见事实、包装、颜色、材质、Logo、文字和风格分析
+- 对话中显式粘贴项目内商品图路径;不依赖 VSCode 图片上传或目录扫描
 - 25 类双语电商模板与平台/行业路由
+- 淘宝/天猫、京东、拼多多独立商品图片资产规则;官方证据不足时明确安全降级,不冒充平台合规
 - 精简七图和 H1-H5/M1-M9 完整十四图预设
 - 主图、副图、详情页、多角度、广告和社媒视觉
 - 商品身份锁、颜色锁、风格锁、本地主图 Data URL 锚点和 Gemini 生成后质量复核
-- 计划/付费执行分离、gateway-unit 预算上限、幂等、停止和部分完成保护
+- 国内平台生成结果的 PNG/JPEG/WebP 真实像素、比例、透明通道和规则状态检查
+- 套图内 `quality_rejected` 单图返修自动归位原运行目录,使用版本化文件名并更新原套图状态
+- 计划/执行分离、单图临时异常最多三次自动尝试、失败不阻断后续图片、原 run 指定项续跑
 - Gemini 高质量默认生图、Seedream 2K 备选生图、两模型编辑和 Seedream 2K 增强
 
 ## 配置
@@ -37,46 +41,52 @@ node bin/fmode-image-set.js workspace --smoke
 ## 使用
 
 ```text
-分析这张商品图,先规划七张套图,不要生图。
+D:\项目\商品正面.png、D:\项目\参考图\商品背面.jpg 是 MORI 咖啡的商品图,先规划七张套图,不要生图。
 ```
 
 ```text
-采用确认的高质量模型计划生成,confirmed=true,budgetLimitCny=5.3。
+采用刚才确认的高质量模型计划生成,confirmed=true。
+
+继续原任务,只重试 D4,不要重新规划或生成已完成图片。
 ```
 
+国内平台必须明确传入 `platform=淘宝/天猫/京东/拼多多`。计划中的 `platformRule` 会显示资产用途、目标规格、证据状态和风险提醒;京东、拼多多当前缺少可公开核验的通用图片数值时使用保守建议,不能描述为官方审核保证。
+
 ## MCP Tools
 
 - `fmode_image_set_analyze`:Gemini 商品识图。
-- `fmode_image_set_plan`:套图规划、模型选择和人民币预算预留
+- `fmode_image_set_plan`:套图规划和模型选择,不向用户展示生成费用
 - `fmode_image_set_templates`:查询模板和预设。
-- `fmode_image_set_generate`:确认后付费生成并接收 Base64 结果。
+- `fmode_image_set_generate`:确认后生成并接收 Base64 结果;也可在原 run 只重试指定 outputId
 - `fmode_image_set_status`:读取本地运行状态。
-- `fmode_image_set_edit`:Gemini/Seedream 图像编辑。
+- `fmode_image_set_edit`:Gemini/Seedream 图像编辑;受管理套图中的当前质量拒绝项会原位返修,普通图片仍创建独立编辑运行
 - `fmode_image_set_upscale`:Seedream 高质量 2K 增强。
 
 ## 输出
 
-运行结果写入调用方项目:
+运行结果写入调用方项目的中文目录
 
 ```text
-outputs/fmode-image-set/<run_id>/
+输出/商品套图/<商品名称>-<8位短请求标识>/
 ```
 
-`state.json` 保存脱敏状态,图片以绝对路径返回。输出根目录固定,Tool 调用不能改写工作区或输出根路径。
+`state.json` 保存脱敏状态,图片以绝对路径返回。完整成功后,项目根直属商品图移动到运行目录的 `商品原图/`,项目子目录商品图复制到该目录,并在结果中逐项报告。部分完成、失败、质量拒绝或 `submission_unknown` 不移动项目根原图。旧 `outputs/fmode-image-set/run_*` 继续支持状态读取,但不再用于新运行。
+
+输出根目录固定,Tool 调用不能改写工作区或输出根路径。
 
 完整目录契约见 `docs/directory-layout.md`。正常流程不会在项目根目录创建 `.tmp-*`;自动测试临时文件统一位于系统 Temp。
 
-## 费用与安全
+## 确认、恢复与安全
 
-- 识图会使用 Gemini 配额;模板查询和本地规划不调用外部模型。
-- 生成、编辑和 2K 增强必须显式 `confirmed: true` 并提供人民币字段 `budgetLimitCny`
-- Seedream 已验证固定价为 0.03 USD/张;Gemini 按量计费,使用 0.10 USD/生成和 0.01 USD/识图或质检的保守预留。两者统一按网关 `usd_exchange_rate=6.8` 换算为人民币
-- 预算会按受信美元价格和 USD/CNY 汇率重新计算,不采信计划中可修改的费用字段
-- 网络中断或响应无法解析时返回 `submission_unknown`,不会自动切换模型或重提
+- 模板查询和本地规划不调用外部模型;生成、编辑和 2K 增强必须显式 `confirmed: true`
+- 普通用户计划、状态和总结不展示预计、累计或实际费用。内部仍保留可信安全上限;`budgetLimitCny` 只作为旧调用兼容字段
+- 单图网络中断、临时网关异常或响应无法解析时,使用稳定幂等键最多自动尝试三次;仍失败则继续后续图片,不自动切换模型
+- 已取得远程结果 URL 时只重试下载,不重新生成。执行结束后列出失败 outputId,用户确认后以原 `runId + retryOutputIds` 续跑
+- 已完成图片是稳定资产:失败恢复不得重新识图、规划或生成已完成项
 - 第一张通过 Gemini 质检后,以本地 Data URL 作为后续图片的身份锚点。
-- 单次最多 14 张;超预算、换模型或扩容必须重新规划并确认。
+- 单次最多 14 张;换模型、扩容或改变设计目标必须重新规划并确认。
 
-成本口径详见 `docs/cost-model.md`
+开发者内部安全上限口径见 `docs/cost-model.md`,不得复制到普通用户输出
 
 ## 测试与排障
 

+ 1 - 1
claude-code/fmode-image-set/bin/fmode-image-set.js

@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
 import { checkPackage } from '../install.js';
 
 const sourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const exclusions = new Set(['node_modules', '.git', '.env', '.env.local', '.claude', '.codex', 'outputs', 'output', '生成结果', 'memory', '.tmp']);
+const exclusions = new Set(['node_modules', '.git', '.env', '.env.local', '.claude', '.codex', 'outputs', 'output', '输出', '生成结果', 'memory', '.tmp']);
 
 function inside(parent, child) {
   const p = path.resolve(parent).toLowerCase();

+ 8 - 2
claude-code/fmode-image-set/docs/capability-map.md

@@ -4,10 +4,12 @@
 |---|---|---|---|---|---|
 | 分析商品图 | `fmode-product-vision` | `fmode_image_set_analyze` | image-analysis | Gemini 3.1 Flash Image Preview | 是 |
 | 规划套图 | `fmode-image-set` | `fmode_image_set_plan` | image-set-planning | 本地规则 | 否 |
+| 规划淘宝/天猫、京东、拼多多商品图 | `fmode-product-main-image` / `fmode-detail-page` | `fmode_image_set_plan` | domestic-platform-planning | 本地规则与版本化证据 | 否 |
 | 查询模板 | 所有 Skill | `fmode_image_set_templates` | template-catalog | 本地文件 | 否 |
 | 生成套图 | `fmode-image-set` 及专项 Skill | `fmode_image_set_generate` | generation-orchestrator | Gemini(默认)/Seedream | 是 |
+| 原 run 指定失败项续跑 | `fmode-image-set` 及专项 Skill | `fmode_image_set_generate` | generation-orchestrator | Gemini(默认)/Seedream | 是 |
 | 查询状态 | `fmode-image-set` | `fmode_image_set_status` | generation-orchestrator | 本地状态 | 否 |
-| 编辑图片 | 专项 Skill | `fmode_image_set_edit` | generation-orchestrator | Gemini/Seedream | 是 |
+| 编辑图片/套图拒绝项原位返修 | `fmode-image-set` | `fmode_image_set_edit` | generation-orchestrator | Gemini/Seedream | 是 |
 | 2K 增强 | 专项 Skill | `fmode_image_set_upscale` | generation-orchestrator | Seedream | 是 |
 
 ## 业务路由
@@ -21,4 +23,8 @@
 
 ## 数据流
 
-`当前请求附件/显式图片 → Gemini 识图 → Brief → 本地计划与模型路由 → 美元价格按受信汇率换算人民币 → 用户确认人民币预算 → New API 同步生图 → Base64/URL 安全落盘 → Gemini 质量检查 → 文件与质量摘要`
+`用户粘贴项目内图片路径(精确 ID 附件仅兼容回退)→ Gemini 识图 → Brief/商品名 → 本地计划与模型路由 → 用户确认 → New API 生图(单项临时异常有限重试,失败后继续)→ 中文运行目录安全落盘 → Gemini 质量检查 → 失败 ID 原 run 指定项续跑 / 全部完成后原图移动复制 → 文件与质量摘要`
+
+国内平台商品图在本地计划阶段增加资产类型和证据状态,生成落盘后增加真实像素/比例检查;未命中国内平台时完全沿用旧数据流。
+
+受管理套图的当前质量拒绝文件进入原位返修支路;普通图片编辑、已通过套图图片编辑和增强仍沿用原独立运行数据流。

+ 4 - 2
claude-code/fmode-image-set/docs/cost-model.md

@@ -1,8 +1,10 @@
-# 成本模型(v0.2.1)
+# 内部成本安全上限(v0.4.0)
+
+> 仅供开发者维护内部执行上限。普通用户计划、状态、错误和总结不得展示本文件中的字段、单价、估算、累计或实际费用。
 
 ## 人民币口径
 
-所有计划、预算门禁和用户输出统一使用人民币:
+内部状态和旧调用兼容门禁使用人民币:
 
 - 计划字段:`estimatedCostCny`
 - 预算字段:`budgetLimitCny`

+ 10 - 5
claude-code/fmode-image-set/docs/customer-quickstart.md

@@ -4,17 +4,22 @@
 2. 为 Claude Code/MCP 进程配置 `FMODE_LLM_BASE_URL` 和 `FMODE_LLM_API_KEY`;远程 HTTP 网关还需显式设置 `FMODE_ALLOW_INSECURE_HTTP=true`。
 3. 在目标工作区运行 `node <包路径>/bin/fmode-image-set.js workspace --smoke`。
 4. 重启 VSCode Claude Code。
-5. 上传商品图并说:“分析这张图,先规划七张套图,不要生图。”
-6. 检查每张图的目标、模型、尺寸、提示词摘要和人民币预算。
-7. 确认后说:“按这个计划生成,`confirmed=true`,`budgetLimitCny=5.3`。”
+5. 把商品图放在当前项目内,在对话中粘贴一个或多个路径并说:“这些是 MORI 咖啡的商品图,先规划七张套图,不要生图。”
+   - 国内平台示例:“这些是 MORI 咖啡的商品图,按淘宝主图方案规划,不要生图。”此时必须明确平台为淘宝/天猫、京东或拼多多,不能依靠模糊猜测。
+6. 检查每张图的目标、模型、尺寸和提示词摘要;系统不会展示生成费用。
+   - 国内平台还要检查 `platformRule` 的资产用途、证据状态和警告;`safe_fallback` 只表示保守设计建议,不代表平台官方审核保证。
+7. 确认后说:“按这个计划生成,`confirmed=true`。”之后可以离开界面等待;单图临时异常会自动尝试,仍失败也会继续后续图片。
 8. 检查每项 `quality.status`;`unverified` 或 `quality_rejected` 需要人工复核。
+   - 国内平台生成结果还会读取真实 PNG/JPEG/WebP 元数据并返回 `platformValidation`;required 规格失败会拒绝交付,recommended 规格只警告并保留文件。
+9. 完整成功后检查中文运行目录和 `商品原图/`:项目根图片应已移动,项目子目录图片应保留并复制;最终消息应逐项给出新路径。
+10. 如果最终列出失败 ID,回复“继续原任务,只重试 D4、D6”。系统必须使用原 `runId + retryOutputIds`,不得重新规划或生成已完成图片。
 
 ## 模型选择
 
 - 默认/`high_quality`/`gemini`:使用 `gemini-3.1-flash-image-preview`,优先商品身份一致性和整体质量。
-- `seedream`:使用 `doubao-seedream-4-0-250828` 的 2K 图片接口,固定生图价格更低
+- `seedream`:使用 `doubao-seedream-4-0-250828` 的 2K 图片接口。
 - 无论使用哪个生图模型,质量检查都使用 Gemini。
-- 失败后不会自动切换模型;换模型是新的付费请求,必须重新规划和确认。
+- 失败后不会自动切换模型;换模型会改变计划,必须重新规划和确认。
 
 ## 凭据缺失
 

+ 6 - 5
claude-code/fmode-image-set/docs/deliverables/image-set-report.md

@@ -6,8 +6,8 @@
 
 - `plan_id`、`plan_hash`、预设和语言;
 - 商品事实来源与未知项;
-- 每个输出的 ID、类型、目标、尺寸、模板、提示词摘要和单价估算
-- 总张数、预计费用、确认要求和警告。
+- 每个输出的 ID、类型、目标、尺寸、模板和提示词摘要
+- 总张数、确认要求和警告;不得返回预计、累计或实际生成费用
 
 不得返回:完整 API Key、Authorization、原始会话日志或付费提交结果。
 
@@ -16,10 +16,11 @@
 必须返回:
 
 - `run_id` 和总体状态;
-- 每个输出的 `planned/submitted/polling/completed/failed/submission_unknown` 状态;
+- 每个输出的 `planned/submitting/retry_wait/receiving/completed/failed/submission_unknown/quality_rejected` 状态;
 - 已完成文件的绝对路径;
-- 已知费用、部分完成情况、错误类别和下一步。
+- 中文运行目录,以及每张本地商品原图“已移动/已复制”的原路径和新路径;
+- 已完成/失败 outputId、错误类别和下一步;失败时询问是否以原 run 只重试这些 outputId。
 
 ## 阻断阶段
 
-必须清楚说明缺少输入、密钥、确认或余额中的哪一项,以及如何从原计划/运行继续。
+必须清楚说明缺少输入、密钥或确认中的哪一项,以及如何从原计划/运行继续;不得显示费用字段或金额

+ 10 - 4
claude-code/fmode-image-set/docs/directory-layout.md

@@ -26,18 +26,24 @@
 所有真实套图运行只允许写入:
 
 ```text
-<workspace>/outputs/fmode-image-set/<run_id>/
+<workspace>/输出/商品套图/<商品名称>-<8位短请求标识>/
 ├── state.json
+├── 商品原图/
+│   ├── 01-商品正面.png
+│   └── 02-商品背面.jpg
 ├── S1-1.png
 ├── S2-1.jpg
 └── ...
 ```
 
-- `run_id` 由 `sessionId + promptId + planHash` 确定,同一请求不会换目录或重复创建付费任务。
+- 运行目录名优先使用用户声明商品名,其次使用明确识图名称,并附加 request key 前 8 位;同一请求不会换目录或重复创建付费任务。
 - `state.json` 是脱敏运行状态;图片文件名由计划的 `outputId` 决定。
 - 部分完成、质量拒绝或提交不确定仍保留在同一个运行目录。
+- 全部计划项完成后,项目根直属商品图移动到 `商品原图/`,项目子目录商品图复制到该目录;整理前重新验证真实路径和内容哈希。
 - Skill 不会把结果写入插件源码、`.claude/`、项目根目录或任意自定义位置。
 
+旧版 `outputs/fmode-image-set/run_*/` 不自动迁移或删除,状态查询和恢复继续兼容。
+
 ## 测试与临时文件
 
 - 自动测试必须使用操作系统 Temp(Windows 通常是 `%LOCALAPPDATA%/Temp`)。
@@ -52,12 +58,12 @@
 | `.claude/plugins/fmode-image-set` | 不建议手动删除 | 由安装器升级;删除后 Tool 无法启动 |
 | `.claude/skills/fmode-*` | 不建议手动删除 | 由安装器升级;删除后 Skill 无法发现 |
 | `.mcp.json` 中本 Server 配置 | 不建议手动删除 | 删除后 MCP Tool 不可用 |
-| `outputs/fmode-image-set/<run_id>` | 用户确认后可删除 | 一个运行整体归档或删除,不要只删 `state.json` |
+| `输出/商品套图/<商品名称>-<短标识>` | 用户确认后可删除 | 一个运行整体归档或删除,不要只删 `state.json` |
 | 系统 Temp 中测试目录 | 可删除 | 正常测试会自动清理 |
 
 ## 禁止行为
 
 - 不以日期、模型或调试名称在项目根随意新建输出目录。
 - 不把真实结果混入测试目录。
-- 不把测试 fixture 写到 `outputs/fmode-image-set/`。
+- 不把测试 fixture 写到 `输出/商品套图/` 或旧 `outputs/fmode-image-set/`。
 - 不自动清理真实运行结果。

+ 25 - 8
claude-code/fmode-image-set/docs/live-manual-acceptance-checklist.md

@@ -9,27 +9,42 @@
 ## Gemini 识图
 
 - [ ] 显式图片路径可分析。
-- [ ] 当前请求附件可分析,即使文本显示 `[Unsupported Image]`
-- [ ] 当前请求无图时不复用旧附件
-- [ ] 多图未标角色时返回 `needs_input`。
+- [ ] 用户声明多张路径均为同一商品图时可全部作为 identity 分析
+- [ ] 不通过 Glob 或历史文件猜测商品图
+- [ ] 混合多图未标角色时返回 `needs_input`。
 - [ ] 分析结果区分可见事实、推断和未知项。
+- [ ] 用户声明商品名优先;没有声明时可使用明确识图名称。
 
 ## 计划门禁
 
 - [ ] “只规划”不调用生图接口。
 - [ ] 默认模型是 `gemini-3.1-flash-image-preview`。
-- [ ] Seedream 计划价格按 0.03 USD/张加 Gemini 质检预留,再按受信汇率换算人民币
+- [ ] 用户可见计划不包含预计、累计或实际费用字段/金额
 - [ ] 未确认生成返回 `needs_confirmation`。
-- [ ] 超过 `budgetLimitCny` 返回 `needs_confirmation`
+- [ ] 不传 `budgetLimitCny` 也可在明确确认后执行;旧调用传入过低上限仍返回 `needs_confirmation` 且不显示金额
 - [ ] 同一 request key 不重复提交。
 
-## 真实生成(会产生费用
+## 真实生成(外部写动作
 
-- [ ] 用户明确确认张数、模型、预算和计划。
+- [ ] 用户明确确认张数、模型和计划。
 - [ ] Gemini Markdown Data URL 能写入项目输出目录。
 - [ ] Seedream `b64_json` 能写入项目输出目录。
 - [ ] 本地参考图无需公网 URL。
-- [ ] `submission_unknown` 停止后续项目且不自动换模型/重提。
+- [ ] 临时异常在同一 outputId 使用稳定幂等键最多尝试三次;耗尽后继续后续项目且不自动换模型。
+- [ ] 最终只列出失败 outputId 并询问是否补图;确认后原 `runId + retryOutputIds` 只生成指定项,已完成 requestId/文件保持不变。
+- [ ] 远程结果 URL 下载失败只重试下载,不再次调用生图接口。
+- [ ] 新运行目录为 `输出/商品套图/<商品名称>-<短标识>/`。
+- [ ] 完整成功后项目根参考图移动、项目子目录参考图复制到 `商品原图/`,并逐项报告路径。
+- [ ] 部分完成、失败、质量拒绝和 `submission_unknown` 不移动项目根参考图。
+- [ ] 旧 `outputs/fmode-image-set/run_*` 状态仍可查询且不会重复生成。
+
+## 套图单图返修(外部写动作)
+
+- [ ] 对 `quality_rejected` item 的当前文件调用 edit,结果保存为原目录中的 `<outputId>-v2-1.<扩展名>`,未创建顶层“单图处理”目录。
+- [ ] 原始失败文件仍保留,原 item 的 `revisions` 记录来源、结果和质量;用户输出不展示内部费用。
+- [ ] 返修通过后原 item/run 状态更新;全部通过后才整理商品原图。
+- [ ] 相同返修确认重复调用不再生图;`submission_unknown` 禁止新请求,已持久化远程 URL 可由原 run status refresh 恢复。
+- [ ] 普通独立图片 edit 仍创建独立运行,且不移动源图。
 
 ## 质量
 
@@ -38,3 +53,5 @@
 - [ ] 多角度人物身份、服装和配饰一致。
 - [ ] 平台尺寸、安全区和文字规则正确。
 - [ ] 没有伪造平台 UI、账号数据或工具水印。
+- [ ] 淘宝/天猫、京东、拼多多计划逐项包含资产类型、证据状态和风险提醒;安全降级未被描述为官方合规。
+- [ ] 国内平台结果报告真实文件格式、像素、比例和透明通道;违反官方必需比例时保留文件并拒绝作为身份锚点。

+ 4 - 4
claude-code/fmode-image-set/docs/troubleshooting.md

@@ -14,11 +14,11 @@ API Key 会在 HTTP 连接中明文传输。优先要求网关提供 HTTPS;如
 
 ## `[Unsupported Image]`
 
-不要重新上传。Skill 会优先调用视觉 Tool,附件解析器会从当前 Claude 请求中定位图片。若仍失败,传入当前项目内图片的显式绝对路径
+不要让 Skill Glob 或猜测历史图片。首选把商品图保存到当前项目内,并在对话中粘贴图片的显式绝对路径或相对路径。带精确 `sessionId + promptId + cwd` 的旧附件解析仅作为兼容回退
 
 ## `submission_unknown`
 
-请求可能已被网关接收并计费。执行器会停止后续付费请求,不自动重提,也不自动切换到另一个模型。保留 `run_id`,通过网关日志或支持渠道人工核验后,再由用户明确授权下一步
+请求是否已被网关接收无法确定。执行器会为当前 outputId 使用同一稳定幂等键最多自动尝试三次;耗尽后记录该项并继续后续图片,不自动切换模型。任务结束后保留 `runId`,只询问是否重试失败 outputId;确认后在原 run 续跑,不重新规划整套
 
 ## `quality_rejected` / `unverified`
 
@@ -27,8 +27,8 @@ API Key 会在 HTTP 连接中明文传输。优先要求网关提供 HTTPS;如
 
 ## 状态查询
 
-当前 New API 链路同步返回图片,不再轮询旧即梦 workId。`fmode_image_set_status` 默认读取脱敏本地状态;若响应已持久化远程结果 URL,可用 `refresh=true` 恢复下载,但不会再次调用付费生图。
+当前 New API 链路同步返回图片,不再轮询旧即梦 workId。`fmode_image_set_status` 默认读取脱敏本地状态;若响应已持久化远程结果 URL,可用 `refresh=true` 恢复下载,但不会再次调用生图接口
 
 ## 输出路径被拒绝
 
-输出根目录固定为调用方项目的 `outputs/fmode-image-set/`,不能由 Tool 参数改写,也不能经过符号链接写入 Skill 源码、其他运行目录或项目外路径。
+新输出根目录固定为调用方项目的 `输出/商品套图/`,不能由 Tool 参数改写,也不能经过符号链接写入 Skill 源码、其他运行目录或项目外路径。旧 `outputs/fmode-image-set/` 仅用于读取既有运行。

+ 170 - 0
claude-code/fmode-image-set/mcp/catalog/domestic-platforms.json

@@ -0,0 +1,170 @@
+{
+  "schemaVersion": 1,
+  "verifiedAt": "2026-08-11",
+  "evidence": {
+    "TB-2024-01-26-MAIN-RATIO": {
+      "title": "商品接口调整通知",
+      "platform": "taobao_tmall",
+      "sourceType": "official_announcement",
+      "sourceUrl": "https://developer.alibaba.com/support/announcementDetail.htm?id=25721",
+      "publishedAt": "2024-01-26",
+      "verifiedAt": "2026-08-11",
+      "verificationStatus": "official_verified",
+      "scope": "商品发布与编辑主图比例;公告说明淘宝、天猫图片上传接口相同",
+      "facts": [
+        "1:1 主图仅可上传 1:1 图片",
+        "3:4 主图仅支持上传 3:4 图片",
+        "官方建议优先维护 1:1 主图"
+      ]
+    },
+    "JD-RULE-CENTER": {
+      "title": "京东规则中心",
+      "platform": "jingdong",
+      "sourceType": "official_rule_center",
+      "sourceUrl": "https://rule.jd.com/",
+      "verifiedAt": "2026-08-11",
+      "verificationStatus": "official_entry_only",
+      "scope": "官方规则入口;公开页面依赖 JavaScript,未取得可核验的通用商品图片具体数值"
+    },
+    "PDD-HELP-CENTER": {
+      "title": "拼多多商家帮助中心",
+      "platform": "pinduoduo",
+      "sourceType": "official_help_center",
+      "sourceUrl": "https://mms.pinduoduo.com/help-center",
+      "verifiedAt": "2026-08-11",
+      "verificationStatus": "official_entry_only",
+      "scope": "官方帮助入口;未取得可公开核验的通用商品图片具体数值"
+    }
+  },
+  "defaults": {
+    "target": {
+      "width": null,
+      "height": null,
+      "ratio": null,
+      "safeZone": "主体保留 8% 外边距",
+      "textPolicy": "仅使用用户确认或图片可见的简短中文事实,不生成价格、认证、功效、竞品标识或平台 UI",
+      "background": "按资产用途选择;白底资产使用纯白背景"
+    },
+    "enforcement": {
+      "ratio": "recommended",
+      "dimensions": "recommended",
+      "background": "recommended",
+      "textPolicy": "recommended"
+    },
+    "verificationStatus": "safe_fallback",
+    "warnings": [
+      "未取得该资产全部字段的官方可核验证据;当前为保守设计建议,不能声称平台官方合规。"
+    ]
+  },
+  "assetDefaults": {
+    "white_background": {
+      "target": {
+        "background": "纯白 #FFFFFF",
+        "alphaAllowed": false,
+        "width": 2048,
+        "height": 2048,
+        "ratio": "1:1"
+      },
+      "enforcement": {
+        "background": "recommended",
+        "alpha": "recommended"
+      }
+    },
+    "listing_primary": {
+      "target": {
+        "background": "简洁商品背景,禁止竞品标识、虚构价格和平台 UI",
+        "width": 2048,
+        "height": 2048,
+        "ratio": "1:1"
+      }
+    }
+  },
+  "platforms": {
+    "taobao": {
+      "label": "淘宝/天猫",
+      "aliases": [
+        "淘宝/天猫",
+        "淘宝天猫",
+        "淘宝",
+        "天猫",
+        "taobao",
+        "tmall",
+        "taobao.com",
+        "tmall.com"
+      ],
+      "ruleCenterUrl": "https://rule.taobao.com/",
+      "assets": {
+        "listing_primary": {
+          "uploadRole": "商品 1:1 主图",
+          "verificationStatus": "official_verified_partial",
+          "enforcement": {
+            "ratio": "required"
+          },
+          "warnings": [
+            "1:1 比例有官方公告证据;具体像素、文件大小、背景和类目附加要求仍需商家后台复核。"
+          ],
+          "evidenceIds": [
+            "TB-2024-01-26-MAIN-RATIO"
+          ]
+        },
+        "white_background": {
+          "uploadRole": "白底商品图候选",
+          "verificationStatus": "official_verified_partial",
+          "enforcement": {
+            "ratio": "required",
+            "background": "recommended"
+          },
+          "warnings": [
+            "1:1 比例有官方公告证据;白底图位置和具体像素未由当前证据证明。"
+          ],
+          "evidenceIds": [
+            "TB-2024-01-26-MAIN-RATIO"
+          ]
+        }
+      }
+    },
+    "jingdong": {
+      "label": "京东",
+      "aliases": [
+        "京东",
+        "jd",
+        "jd.com",
+        "jingdong",
+        "jing dong"
+      ],
+      "ruleCenterUrl": "https://rule.jd.com/",
+      "evidenceIds": [
+        "JD-RULE-CENTER"
+      ],
+      "assets": {
+        "listing_primary": {
+          "uploadRole": "商品主图候选"
+        },
+        "white_background": {
+          "uploadRole": "白底商品图候选"
+        }
+      }
+    },
+    "pinduoduo": {
+      "label": "拼多多",
+      "aliases": [
+        "拼多多",
+        "pdd",
+        "pinduoduo",
+        "pinduoduo.com"
+      ],
+      "ruleCenterUrl": "https://mms.pinduoduo.com/help-center",
+      "evidenceIds": [
+        "PDD-HELP-CENTER"
+      ],
+      "assets": {
+        "listing_primary": {
+          "uploadRole": "商品轮播主图候选"
+        },
+        "white_background": {
+          "uploadRole": "独立白底商品图候选"
+        }
+      }
+    }
+  }
+}

+ 16 - 4
claude-code/fmode-image-set/mcp/src/core/attachment-resolver.mjs

@@ -1,7 +1,8 @@
 import fs from 'node:fs';
 import os from 'node:os';
 import path from 'node:path';
-import { isInside } from './run-paths.mjs';
+import { createHash } from 'node:crypto';
+import { assertExistingPathChainInside, isInside } from './run-paths.mjs';
 
 const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
 const MIME = new Map([
@@ -14,6 +15,7 @@ function fileImage(filePath, cwd, maxBytes = MAX_IMAGE_BYTES, options = {}) {
   if (!fs.existsSync(workspaceRoot)) throw new Error('当前项目目录不存在。');
   const realWorkspace = fs.realpathSync.native(workspaceRoot);
   const absolutePath = path.resolve(cwd, filePath);
+  assertExistingPathChainInside(realWorkspace, absolutePath, { kind: '商品图片路径' });
   const linkStat = fs.lstatSync(absolutePath);
   if (linkStat.isSymbolicLink()) throw new Error('商品图片不能通过符号链接读取。');
   const realPath = fs.realpathSync.native(absolutePath);
@@ -23,7 +25,16 @@ function fileImage(filePath, cwd, maxBytes = MAX_IMAGE_BYTES, options = {}) {
   if (stat.size > maxBytes) throw new Error(`图片超过 ${Math.round(maxBytes / 1024 / 1024)}MB:${path.basename(absolutePath)}`);
   const mimeType = MIME.get(path.extname(absolutePath).toLowerCase());
   if (!mimeType) throw new Error(`不支持的图片格式:${path.extname(absolutePath)}`);
-  return { url: `data:${mimeType};base64,${fs.readFileSync(realPath).toString('base64')}`, label: path.basename(realPath), source: 'file' };
+  const bytes = fs.readFileSync(realPath);
+  return {
+    url: `data:${mimeType};base64,${bytes.toString('base64')}`,
+    label: path.basename(realPath),
+    source: 'file',
+    localPath: realPath,
+    workspaceRelativePath: path.relative(realWorkspace, realPath),
+    contentHash: createHash('sha256').update(bytes).digest('hex'),
+    size: stat.size
+  };
 }
 
 function normalizeImage(item, input) {
@@ -110,8 +121,9 @@ export function resolveImageInputs(input = {}) {
   const unique = [];
   const seen = new Set();
   for (const image of images) {
-    if (!seen.has(image.url)) unique.push(image);
-    seen.add(image.url);
+    const key = image.localPath ? `file:${process.platform === 'win32' ? image.localPath.toLowerCase() : image.localPath}` : `content:${image.url}`;
+    if (!seen.has(key)) unique.push(image);
+    seen.add(key);
   }
   if (unique.length > Number(input.maxImages || 10)) throw new Error('图片数量超过上限,请明确选择最多 10 张参考图。');
   return unique;

+ 124 - 3
claude-code/fmode-image-set/mcp/src/core/core.test.mjs

@@ -3,9 +3,10 @@ import fs from 'node:fs';
 import os from 'node:os';
 import path from 'node:path';
 import test from 'node:test';
-import { resolveClaudeAttachments } from './attachment-resolver.mjs';
+import { resolveClaudeAttachments, resolveImageInputs } from './attachment-resolver.mjs';
+import { toPublicValue } from './public-view.mjs';
 import { createRequestKey } from './request-key.mjs';
-import { isInside, resolveOutputRoot, resolveRunDirectory } from './run-paths.mjs';
+import { createReadableRunId, isInside, resolveExistingRunDirectory, resolveOutputRoot, resolveRunDirectory, sanitizeProductName } from './run-paths.mjs';
 import { readState, writeState } from './state-store.mjs';
 
 const projectRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..');
@@ -20,13 +21,89 @@ test('request key is deterministic', () => {
   assert.equal(a, b);
 });
 
+test('public projection removes nested cost data and sanitizes user-facing arrays', () => {
+  const projected = toPublicValue({
+    assistantMessage: '按受信美元价格与汇率预计 ¥5.30。',
+    summary: { estimatedCostCny: 5.3, total_cost: 8, nested: { totalCostCny: 6, value: 'keep' } },
+    data: { billing_info: { amount: 4 }, run: { budgetLimitCny: 7, usage: { cost: 1.25, actual_cost_cny: 3 }, items: [{ costUnit: 'CNY', revisionEstimatedCostCny: 1 }] } },
+    nextActions: ['人民币预算上限不足 ¥5.30。', '累计费用 CNY 3.00 / ¥3.00']
+  });
+  assert.deepEqual(projected.summary, { nested: { value: 'keep' } });
+  assert.deepEqual(projected.data, { run: { items: [{}] } });
+  assert.doesNotMatch(JSON.stringify(projected), /estimatedCostCny|total_?cost|actual_?cost|budgetLimitCny|costUnit|revisionEstimatedCostCny|usage|billing|¥|¥|CNY|RMB|费用|5\.30|3\.00/);
+});
+
 test('run paths reject project escape and skill source outputs', () => {
   const packageRoot = path.join(projectRoot, 'fmode-image-set');
   assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.resolve(projectRoot, '..', 'escape') }), /固定/);
   assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.join(projectRoot, 'custom-output') }), /固定/);
   assert.throws(() => resolveOutputRoot({ workspaceRoot: packageRoot, packageRoot }), /Skill 源码/);
-  const paths = resolveRunDirectory({ workspaceRoot: projectRoot, packageRoot, runId: 'run_abcdef' });
+  const paths = resolveRunDirectory({ workspaceRoot: projectRoot, packageRoot, runId: '咖啡豆-abcdef12' });
   assert.equal(isInside(projectRoot, paths.runDirectory), true);
+  assert.equal(paths.runDirectory, path.join(projectRoot, '输出', '商品套图', '咖啡豆-abcdef12'));
+});
+
+test('run paths reject a pre-created run directory symlink or junction', () => {
+  const workspace = tempDirectory('run-link-workspace');
+  const outside = tempDirectory('run-link-outside');
+  const outputRoot = path.join(workspace, '输出', '商品套图');
+  const linkedRun = path.join(outputRoot, '链接商品-abcdef12');
+  fs.mkdirSync(outputRoot, { recursive: true });
+  try {
+    fs.symlinkSync(outside, linkedRun, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => resolveRunDirectory({ workspaceRoot: workspace, runId: '链接商品-abcdef12' }), /符号链接|联接点/);
+  } finally {
+    if (fs.existsSync(linkedRun)) fs.unlinkSync(linkedRun);
+    fs.rmSync(workspace, { recursive: true, force: true });
+    fs.rmSync(outside, { recursive: true, force: true });
+  }
+});
+
+test('readable run ids sanitize Windows names and keep a stable request suffix', () => {
+  assert.equal(sanitizeProductName(' MORI 咖啡/经典版:*? '), 'MORI-咖啡-经典版');
+  assert.equal(sanitizeProductName('CON'), 'CON-商品');
+  assert.equal(sanitizeProductName('CON.txt'), 'CON-txt');
+  assert.equal(sanitizeProductName('A&B(经典版)☕'), 'A-B-经典版');
+  assert.equal(createReadableRunId({ productName: 'MORI 咖啡', requestKey: 'ABCDEF1234567890' }), 'MORI-咖啡-abcdef12');
+  assert.throws(() => createReadableRunId({ productName: '咖啡', requestKey: 'bad' }), /稳定/);
+});
+
+test('existing run lookup supports the legacy output directory', () => {
+  const workspace = tempDirectory('legacy-run');
+  const legacy = resolveRunDirectory({ workspaceRoot: workspace, runId: 'run_abcdef', layout: 'legacy' });
+  fs.mkdirSync(legacy.runDirectory, { recursive: true });
+  fs.writeFileSync(path.join(legacy.runDirectory, 'state.json'), '{}');
+  try {
+    const found = resolveExistingRunDirectory({ workspaceRoot: workspace, runId: 'run_abcdef' });
+    assert.equal(found.layout, 'legacy');
+  } finally { fs.rmSync(workspace, { recursive: true, force: true }); }
+});
+
+test('explicit local paths deduplicate the same path but preserve distinct files with identical content', () => {
+  const workspace = tempDirectory('local-dedup');
+  const first = path.join(workspace, 'first.png');
+  const second = path.join(workspace, 'second.png');
+  fs.writeFileSync(first, Buffer.from([137, 80, 78, 71]));
+  fs.copyFileSync(first, second);
+  try {
+    const images = resolveImageInputs({ images: [first, first, second], workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false });
+    assert.equal(images.length, 2);
+    assert.deepEqual(images.map(image => image.label), ['first.png', 'second.png']);
+  } finally { fs.rmSync(workspace, { recursive: true, force: true }); }
+});
+
+test('explicit local images reject an internal directory junction or symlink', () => {
+  const workspace = tempDirectory('image-link-workspace');
+  const rootImage = path.join(workspace, 'root.png');
+  const alias = path.join(workspace, 'alias');
+  fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71]));
+  try {
+    fs.symlinkSync(workspace, alias, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => resolveImageInputs({ imagePath: path.join(alias, 'root.png'), workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }), /符号链接|联接点/);
+  } finally {
+    if (fs.existsSync(alias)) fs.unlinkSync(alias);
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
 });
 
 test('Claude attachment filters require exact session, prompt, and cwd fields', () => {
@@ -56,6 +133,50 @@ test('state checksum detects tampering', () => {
   } finally { fs.rmSync(run, { recursive: true, force: true }); }
 });
 
+test('state read and write reject symlinked or hard-linked state files and temp files', () => {
+  const run = tempDirectory('state-link-run');
+  const outside = tempDirectory('state-link-outside');
+  const outsideFile = path.join(outside, 'outside.json');
+  fs.writeFileSync(outsideFile, 'outside');
+  const stateFile = path.join(run, 'state.json');
+  const tempFile = path.join(run, 'state.json.tmp');
+  try {
+    if (process.platform === 'win32') fs.linkSync(outsideFile, stateFile);
+    else fs.symlinkSync(outsideFile, stateFile, 'file');
+    assert.throws(() => readState(run), /普通.*文件/);
+    assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
+    fs.unlinkSync(stateFile);
+    if (process.platform === 'win32') fs.linkSync(outsideFile, tempFile);
+    else fs.symlinkSync(outsideFile, tempFile, 'file');
+    assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
+    assert.equal(fs.readFileSync(outsideFile, 'utf8'), 'outside');
+  } finally {
+    if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
+    if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
+    fs.rmSync(run, { recursive: true, force: true });
+    fs.rmSync(outside, { recursive: true, force: true });
+  }
+});
+
+test('state read and write reject directory junctions at state paths', () => {
+  const run = tempDirectory('state-junction-run');
+  const outside = tempDirectory('state-junction-outside');
+  const stateFile = path.join(run, 'state.json');
+  const tempFile = path.join(run, 'state.json.tmp');
+  try {
+    fs.symlinkSync(outside, tempFile, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
+    fs.unlinkSync(tempFile);
+    fs.symlinkSync(outside, stateFile, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => readState(run), /普通.*文件/);
+  } finally {
+    if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
+    if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
+    fs.rmSync(run, { recursive: true, force: true });
+    fs.rmSync(outside, { recursive: true, force: true });
+  }
+});
+
 test('latest Claude user request with no image never reuses older attachment', () => {
   const config = tempDirectory('claude');
   const logs = path.join(config, 'projects', 'fixture');

+ 48 - 0
claude-code/fmode-image-set/mcp/src/core/public-view.mjs

@@ -0,0 +1,48 @@
+const PRIVATE_COST_KEYS = new Set([
+  'estimatedCostCny', 'revisionEstimatedCostCny', 'budgetLimitCny', 'budget_limit_cny',
+  'costUnit', 'costBasis', 'estimatedCostUsd', 'actualCostCny', 'totalCostCny', 'usage', 'billing'
+]);
+
+const USER_MESSAGE_KEYS = new Set([
+  'assistantMessage', 'confirmationMessage', 'error', 'errors', 'warning', 'warnings', 'nextActions'
+]);
+
+export function sanitizeUserText(value = '') {
+  return String(value)
+    .replace(/按受信美元价格与汇率预计\s*¥[\d.]+[,。]?/g, '')
+    .replace(/人民币预算上限不足[^。]*。?/g, '内部执行上限不足,请重新确认。')
+    .replace(/服务端是否已(?:被上游接收或)?计费未知/g, '请求是否已被上游接收未知')
+    .replace(/计费未知/g, '请求状态未知')
+    .replace(/(?:CNY|RMB)\s*[¥¥]?\s*\d+(?:\.\d+)?/gi, '')
+    .replace(/[¥¥]\s*\d+(?:\.\d+)?/g, '')
+    .replace(/(?:预计|累计|总计|合计|实际|本次|生成|返修)?(?:费用|成本|花费|计费金额)\s*[::]?\s*\d+(?:\.\d+)?/g, '')
+    .replace(/(?:预计|累计|总计|合计|实际|本次|生成|返修)(?:费用|成本|花费|计费金额)\s*[::]?/g, '')
+    .replace(/^\s*\/\s*|\s*\/\s*$/g, '')
+    .replace(/\s{2,}/g, ' ')
+    .trim();
+}
+
+function isPrivateCostKey(key = '') {
+  if (PRIVATE_COST_KEYS.has(key)) return true;
+  const normalized = String(key).replace(/[^A-Za-z0-9]/g, '').toLowerCase();
+  return /^(?:usage|billing|billable|cost|fee|charge|budget)$/.test(normalized)
+    || /^(?:billing|billable)(?:info|detail|details|amount|summary|data)/.test(normalized)
+    || /^budget(?:limit|amount|cny|rmb|usd|total|actual|estimated)/.test(normalized)
+    || /^(?:estimated|actual|total|cumulative|accumulated|generation|revision|request)(?:cost|fee|charge|budget)/.test(normalized)
+    || /^(?:cost|fee|charge|budget)(?:cny|rmb|usd|amount|unit|basis|total|actual|estimated)$/.test(normalized);
+}
+
+export function toPublicValue(value, key = '') {
+  if (Array.isArray(value)) return value.map(item => toPublicValue(item, key));
+  if (!value || typeof value !== 'object') {
+    return typeof value === 'string' && USER_MESSAGE_KEYS.has(key) ? sanitizeUserText(value) : value;
+  }
+  const output = {};
+  for (const [entryKey, entryValue] of Object.entries(value)) {
+    if (isPrivateCostKey(entryKey)) continue;
+    output[entryKey] = toPublicValue(entryValue, entryKey);
+  }
+  return output;
+}
+
+export { PRIVATE_COST_KEYS };

+ 3 - 1
claude-code/fmode-image-set/mcp/src/core/result-envelope.mjs

@@ -1,3 +1,5 @@
+import { toPublicValue } from './public-view.mjs';
+
 const STATUSES = new Set([
   'ok', 'needs_input', 'needs_token', 'needs_valid_token', 'needs_confirmation',
   'needs_recharge', 'provider_unavailable', 'partial', 'submission_unknown',
@@ -27,7 +29,7 @@ export function errorResult(status, assistantMessage, payload = {}) {
 }
 
 export function asToolResult(envelope) {
-  const value = resultEnvelope(envelope);
+  const value = toPublicValue(resultEnvelope(envelope));
   return {
     content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
     structuredContent: value,

+ 56 - 10
claude-code/fmode-image-set/mcp/src/core/run-paths.mjs

@@ -1,6 +1,9 @@
 import fs from 'node:fs';
 import path from 'node:path';
 
+export const CURRENT_OUTPUT_SEGMENTS = ['输出', '商品套图'];
+export const LEGACY_OUTPUT_SEGMENTS = ['outputs', 'fmode-image-set'];
+
 function normalized(value) {
   const resolved = path.resolve(value);
   return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
@@ -20,7 +23,7 @@ function realExisting(value) {
   return fs.realpathSync.native(path.resolve(value));
 }
 
-function assertExistingPathChainInside(workspace, target) {
+export function assertExistingPathChainInside(workspace, target, { kind = '输出目录' } = {}) {
   const realWorkspace = realExisting(workspace);
   let cursor = path.resolve(workspace);
   const relative = path.relative(cursor, path.resolve(target));
@@ -28,9 +31,9 @@ function assertExistingPathChainInside(workspace, target) {
     cursor = path.join(cursor, segment);
     if (!fs.existsSync(cursor)) break;
     const stat = fs.lstatSync(cursor);
-    if (stat.isSymbolicLink()) throw new Error('输出目录不能经过符号链接或联接点。');
+    if (stat.isSymbolicLink()) throw new Error(`${kind}不能经过符号链接或联接点。`);
     const realCursor = realExisting(cursor);
-    if (!isInside(realWorkspace, realCursor)) throw new Error('输出目录的真实路径越过当前项目边界。');
+    if (!isInside(realWorkspace, realCursor)) throw new Error(`${kind}必须位于当前项目内,真实路径不能越过边界。`);
   }
   return realWorkspace;
 }
@@ -38,20 +41,63 @@ function assertExistingPathChainInside(workspace, target) {
 export function resolveOutputRoot({ workspaceRoot = process.cwd(), outputRoot, packageRoot } = {}) {
   const workspace = path.resolve(workspaceRoot);
   if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) throw new Error('当前项目目录不存在。');
-  const expected = path.join(workspace, 'outputs', 'fmode-image-set');
+  const expected = path.join(workspace, ...CURRENT_OUTPUT_SEGMENTS);
   const root = path.resolve(outputRoot || expected);
-  if (!samePath(root, expected)) throw new Error('输出目录固定为当前项目的 outputs/fmode-image-set。');
+  if (!samePath(root, expected)) throw new Error('输出目录固定为当前项目的 输出/商品套图。');
+  assertExistingPathChainInside(workspace, root);
+  if (packageRoot && isInside(path.resolve(packageRoot), root)) throw new Error('拒绝把运行结果写入 Skill 源码目录。');
+  return root;
+}
+
+export function resolveLegacyOutputRoot({ workspaceRoot = process.cwd(), packageRoot } = {}) {
+  const workspace = path.resolve(workspaceRoot);
+  if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) throw new Error('当前项目目录不存在。');
+  const root = path.join(workspace, ...LEGACY_OUTPUT_SEGMENTS);
   assertExistingPathChainInside(workspace, root);
   if (packageRoot && isInside(path.resolve(packageRoot), root)) throw new Error('拒绝把运行结果写入 Skill 源码目录。');
   return root;
 }
 
-export function resolveRunDirectory({ workspaceRoot, outputRoot, packageRoot, runId }) {
-  if (!/^[A-Za-z0-9_-]{6,96}$/.test(String(runId || ''))) throw new Error('非法 run_id。');
-  const root = resolveOutputRoot({ workspaceRoot, outputRoot, packageRoot });
-  const runDirectory = path.resolve(root, runId);
+export function sanitizeProductName(value, fallback = '未命名商品') {
+  const normalizedValue = String(value || '').normalize('NFKC')
+    .replace(/[^\p{L}\p{N}_-]+/gu, '-')
+    .replace(/-+/g, '-')
+    .replace(/^[.\s_-]+|[.\s_-]+$/g, '');
+  const safe = [...normalizedValue].slice(0, 48).join('').replace(/[.\s_-]+$/g, '');
+  const candidate = safe || fallback;
+  return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) ? `${candidate}-商品` : candidate;
+}
+
+export function createReadableRunId({ productName, requestKey }) {
+  const suffix = String(requestKey || '').toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 8);
+  if (suffix.length !== 8) throw new Error('无法生成稳定运行标识。');
+  return `${sanitizeProductName(productName)}-${suffix}`;
+}
+
+function assertRunId(runId) {
+  const value = String(runId || '');
+  if (value.length < 3 || value.length > 96 || !/^[\p{L}\p{N}_-]+$/u.test(value)) throw new Error('非法运行标识。');
+  return value;
+}
+
+export function resolveRunDirectory({ workspaceRoot, outputRoot, packageRoot, runId, layout = 'current' }) {
+  const safeRunId = assertRunId(runId);
+  const workspace = path.resolve(workspaceRoot || process.cwd());
+  const root = layout === 'legacy'
+    ? resolveLegacyOutputRoot({ workspaceRoot, packageRoot })
+    : resolveOutputRoot({ workspaceRoot, outputRoot, packageRoot });
+  const runDirectory = path.resolve(root, safeRunId);
   if (!isInside(root, runDirectory) || runDirectory === root) throw new Error('非法运行目录。');
-  return { outputRoot: root, runDirectory };
+  assertExistingPathChainInside(workspace, runDirectory);
+  return { outputRoot: root, runDirectory, layout };
+}
+
+export function resolveExistingRunDirectory({ workspaceRoot, packageRoot, runId }) {
+  const candidates = [
+    resolveRunDirectory({ workspaceRoot, packageRoot, runId, layout: 'current' }),
+    resolveRunDirectory({ workspaceRoot, packageRoot, runId, layout: 'legacy' })
+  ];
+  return candidates.find(candidate => fs.existsSync(path.join(candidate.runDirectory, 'state.json'))) || candidates[0];
 }
 
 export function assertSafeFile(runDirectory, filePath) {

+ 26 - 3
claude-code/fmode-image-set/mcp/src/core/state-store.mjs

@@ -1,24 +1,47 @@
 import fs from 'node:fs';
 import path from 'node:path';
 import { sha256, stableStringify } from './request-key.mjs';
-import { assertSafeFile } from './run-paths.mjs';
+import { assertSafeFile, isInside } from './run-paths.mjs';
 
 function checksum(payload) {
   return sha256(stableStringify(payload));
 }
 
+function assertRegularStatePath(runDirectory, filePath, label) {
+  if (!fs.existsSync(filePath)) return;
+  const stat = fs.lstatSync(filePath);
+  if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) throw new Error(`${label}必须是运行目录内的普通独占文件。`);
+  const realRun = fs.realpathSync.native(runDirectory);
+  const realFile = fs.realpathSync.native(filePath);
+  if (!isInside(realRun, realFile)) throw new Error(`${label}真实路径越过运行目录边界。`);
+}
+
 export function writeState(runDirectory, payload) {
   fs.mkdirSync(runDirectory, { recursive: true });
+  const runStat = fs.lstatSync(runDirectory);
+  if (runStat.isSymbolicLink() || !runStat.isDirectory()) throw new Error('运行目录必须是普通目录。');
   const filePath = assertSafeFile(runDirectory, path.join(runDirectory, 'state.json'));
   const document = { schemaVersion: 1, checksum: checksum(payload), payload };
   const tempPath = assertSafeFile(runDirectory, `${filePath}.tmp`);
-  fs.writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
-  fs.renameSync(tempPath, filePath);
+  assertRegularStatePath(runDirectory, filePath, '状态文件');
+  assertRegularStatePath(runDirectory, tempPath, '状态临时文件');
+  if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
+  try {
+    fs.writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
+    assertRegularStatePath(runDirectory, tempPath, '状态临时文件');
+    fs.renameSync(tempPath, filePath);
+  } catch (error) {
+    try {
+      if (fs.existsSync(tempPath) && !fs.lstatSync(tempPath).isSymbolicLink()) fs.unlinkSync(tempPath);
+    } catch { /* Keep the primary state error. */ }
+    throw error;
+  }
   return filePath;
 }
 
 export function readState(runDirectory) {
   const filePath = assertSafeFile(runDirectory, path.join(runDirectory, 'state.json'));
+  assertRegularStatePath(runDirectory, filePath, '状态文件');
   const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
   if (document.schemaVersion !== 1 || document.checksum !== checksum(document.payload)) {
     throw new Error('运行状态完整性校验失败。');

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1052 - 79
claude-code/fmode-image-set/mcp/src/features/generation/orchestrator.mjs


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1047 - 16
claude-code/fmode-image-set/mcp/src/features/generation/orchestrator.test.mjs


+ 131 - 0
claude-code/fmode-image-set/mcp/src/features/generation/platform-validation.mjs

@@ -0,0 +1,131 @@
+import fs from 'node:fs';
+
+function pngMetadata(bytes) {
+  const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+  if (bytes.length < 26 || !bytes.subarray(0, 8).equals(signature) || bytes.toString('ascii', 12, 16) !== 'IHDR') return null;
+  const colorType = bytes[25];
+  let hasTransparencyChunk = false;
+  for (let offset = 8; offset + 12 <= bytes.length;) {
+    const length = bytes.readUInt32BE(offset);
+    if (offset + 12 + length > bytes.length) break;
+    if (bytes.toString('ascii', offset + 4, offset + 8) === 'tRNS') hasTransparencyChunk = true;
+    offset += 12 + length;
+  }
+  return {
+    format: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20),
+    hasAlpha: colorType === 4 || colorType === 6 || hasTransparencyChunk
+  };
+}
+
+function jpegMetadata(bytes) {
+  if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
+  const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
+  let offset = 2;
+  while (offset + 8 < bytes.length) {
+    if (bytes[offset] !== 0xff) { offset += 1; continue; }
+    while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
+    const marker = bytes[offset];
+    offset += 1;
+    if (marker === 0xd8 || marker === 0xd9) continue;
+    if (marker === 0xda) break;
+    if (offset + 2 > bytes.length) break;
+    const length = bytes.readUInt16BE(offset);
+    if (length < 2 || offset + length > bytes.length) break;
+    if (sof.has(marker) && length >= 7) {
+      return { format: 'jpeg', width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3), hasAlpha: false };
+    }
+    offset += length;
+  }
+  return null;
+}
+
+function webpMetadata(bytes) {
+  if (bytes.length < 20 || bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WEBP') return null;
+  const kind = bytes.toString('ascii', 12, 16);
+  if (kind === 'VP8X' && bytes.length >= 30) {
+    const width = 1 + bytes.readUIntLE(24, 3);
+    const height = 1 + bytes.readUIntLE(27, 3);
+    return { format: 'webp', width, height, hasAlpha: Boolean(bytes[20] & 0x10) };
+  }
+  if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
+    return { format: 'webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff, hasAlpha: false };
+  }
+  if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
+    const width = 1 + (((bytes[22] & 0x3f) << 8) | bytes[21]);
+    const height = 1 + (((bytes[24] & 0x0f) << 10) | (bytes[23] << 2) | ((bytes[22] & 0xc0) >> 6));
+    return { format: 'webp', width, height, hasAlpha: Boolean(bytes[24] & 0x10) };
+  }
+  return null;
+}
+
+export function readImageMetadata(filePath) {
+  const bytes = fs.readFileSync(filePath);
+  const metadata = pngMetadata(bytes) || jpegMetadata(bytes) || webpMetadata(bytes);
+  if (!metadata || !metadata.width || !metadata.height) throw new Error('无法读取生成图片的真实格式或像素尺寸。');
+  return { ...metadata, bytes: bytes.length };
+}
+
+function parseRatio(value) {
+  const match = String(value || '').match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
+  if (!match) return null;
+  return Number(match[1]) / Number(match[2]);
+}
+
+function checked(name, enforcement, passed, expected, actual, message) {
+  const level = enforcement || 'recommended';
+  return { name, enforcement: level, passed, expected, actual, message: passed ? '' : message };
+}
+
+export function validateDomesticImage(filePath, platformRule) {
+  if (!platformRule?.domestic) return null;
+  let metadata;
+  try { metadata = readImageMetadata(filePath); }
+  catch (error) {
+    return {
+      status: 'rejected', passed: false, filePath, metadata: null,
+      checks: [], violations: [error.message], warnings: []
+    };
+  }
+  const target = platformRule.target || {};
+  const enforcement = platformRule.enforcement || {};
+  const checks = [];
+  const expectedRatio = parseRatio(target.ratio);
+  if (expectedRatio) {
+    const actualRatio = metadata.width / metadata.height;
+    const passed = Math.abs(actualRatio - expectedRatio) <= 0.005;
+    checks.push(checked('ratio', enforcement.ratio, passed, target.ratio, `${metadata.width}:${metadata.height}`, `实际比例 ${metadata.width}:${metadata.height} 不符合目标 ${target.ratio}。`));
+  }
+  if (target.width && target.height) {
+    const passed = metadata.width === Number(target.width) && metadata.height === Number(target.height);
+    checks.push(checked('dimensions', enforcement.dimensions, passed, `${target.width}x${target.height}`, `${metadata.width}x${metadata.height}`, `实际像素 ${metadata.width}x${metadata.height} 与交付建议 ${target.width}x${target.height} 不一致。`));
+  }
+  if (target.alphaAllowed === false && metadata.hasAlpha !== null) {
+    checks.push(checked('alpha', enforcement.alpha, metadata.hasAlpha === false, false, metadata.hasAlpha, '白底商品图包含透明通道,交付前应扁平化到纯白背景。'));
+  }
+  if (target.maxBytes) {
+    checks.push(checked('maxBytes', enforcement.maxBytes, metadata.bytes <= Number(target.maxBytes), target.maxBytes, metadata.bytes, `文件大小 ${metadata.bytes} 超过目标上限 ${target.maxBytes}。`));
+  }
+  const violations = checks.filter(item => !item.passed && item.enforcement === 'required').map(item => item.message);
+  const warnings = checks.filter(item => !item.passed && item.enforcement !== 'required').map(item => item.message);
+  return {
+    status: violations.length ? 'rejected' : warnings.length ? 'warning' : 'passed',
+    passed: violations.length === 0,
+    filePath, metadata, checks, violations, warnings
+  };
+}
+
+export function validateDomesticFiles(item) {
+  if (!item?.platformRule?.domestic) return null;
+  const files = (item.files || []).map(filePath => validateDomesticImage(filePath, item.platformRule));
+  const violations = files.flatMap(file => file.violations || []);
+  const warnings = files.flatMap(file => file.warnings || []);
+  return {
+    status: violations.length ? 'rejected' : warnings.length ? 'warning' : 'passed',
+    passed: violations.length === 0,
+    platformKey: item.platformRule.platformKey,
+    assetType: item.platformRule.assetType,
+    verificationStatus: item.platformRule.verificationStatus,
+    files, violations, warnings,
+    ruleWarnings: item.platformRule.warnings || []
+  };
+}

+ 110 - 0
claude-code/fmode-image-set/mcp/src/features/generation/platform-validation.test.mjs

@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { readImageMetadata, validateDomesticImage } from './platform-validation.mjs';
+
+function fakePng(width, height, colorType = 2) {
+  const bytes = Buffer.alloc(33);
+  Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes, 0);
+  bytes.writeUInt32BE(13, 8);
+  bytes.write('IHDR', 12, 'ascii');
+  bytes.writeUInt32BE(width, 16);
+  bytes.writeUInt32BE(height, 20);
+  bytes[24] = 8;
+  bytes[25] = colorType;
+  return bytes;
+}
+
+function fakePngWithTransparencyChunk(width, height) {
+  const transparency = Buffer.alloc(18);
+  transparency.writeUInt32BE(6, 0);
+  transparency.write('tRNS', 4, 'ascii');
+  return Buffer.concat([fakePng(width, height, 2), transparency]);
+}
+
+function fakeJpeg(width, height) {
+  const bytes = Buffer.alloc(23);
+  Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08]).copy(bytes, 0);
+  bytes.writeUInt16BE(height, 7);
+  bytes.writeUInt16BE(width, 9);
+  bytes[11] = 3;
+  Buffer.from([1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xff, 0xd9]).copy(bytes, 12);
+  return bytes;
+}
+
+function fakeWebp(width, height, alpha = false) {
+  const bytes = Buffer.alloc(30);
+  bytes.write('RIFF', 0, 'ascii');
+  bytes.writeUInt32LE(22, 4);
+  bytes.write('WEBPVP8X', 8, 'ascii');
+  bytes.writeUInt32LE(10, 16);
+  bytes[20] = alpha ? 0x10 : 0;
+  bytes.writeUIntLE(width - 1, 24, 3);
+  bytes.writeUIntLE(height - 1, 27, 3);
+  return bytes;
+}
+
+function fakeLosslessWebp(width, height, alpha = false) {
+  const bytes = Buffer.alloc(25);
+  bytes.write('RIFF', 0, 'ascii');
+  bytes.writeUInt32LE(17, 4);
+  bytes.write('WEBPVP8L', 8, 'ascii');
+  bytes.writeUInt32LE(5, 16);
+  bytes[20] = 0x2f;
+  const bits = BigInt(width - 1) | (BigInt(height - 1) << 14n) | (alpha ? 1n << 28n : 0n);
+  bytes.writeUInt32LE(Number(bits), 21);
+  return bytes;
+}
+
+function withFile(bytes, callback) {
+  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-platform-validation-'));
+  const file = path.join(directory, 'candidate.png');
+  fs.writeFileSync(file, bytes);
+  try { return callback(file); }
+  finally { fs.rmSync(directory, { recursive: true, force: true }); }
+}
+
+const requiredSquare = {
+  domestic: true, platformKey: 'taobao', assetType: 'listing_primary', verificationStatus: 'official_verified_partial',
+  target: { width: 2048, height: 2048, ratio: '1:1', alphaAllowed: false },
+  enforcement: { ratio: 'required', dimensions: 'recommended', alpha: 'recommended' }, warnings: []
+};
+
+test('reads PNG dimensions and alpha-channel metadata', () => withFile(fakePng(2048, 2048, 6), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'png', width: 2048, height: 2048, hasAlpha: true, bytes: 33 });
+}));
+
+test('detects indexed or truecolor PNG transparency from tRNS', () => withFile(fakePngWithTransparencyChunk(800, 800), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'png', width: 800, height: 800, hasAlpha: true, bytes: 51 });
+}));
+
+test('reads JPEG dimensions from a start-of-frame marker', () => withFile(fakeJpeg(1600, 1200), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'jpeg', width: 1600, height: 1200, hasAlpha: false, bytes: 23 });
+}));
+
+test('reads extended WebP dimensions and alpha flag', () => withFile(fakeWebp(1000, 1000, true), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'webp', width: 1000, height: 1000, hasAlpha: true, bytes: 30 });
+}));
+
+test('reads lossless WebP dimensions and alpha flag', () => withFile(fakeLosslessWebp(641, 853, true), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'webp', width: 641, height: 853, hasAlpha: true, bytes: 25 });
+}));
+
+test('required domestic ratio rejects a mismatched image', () => withFile(fakePng(2048, 1536), file => {
+  const result = validateDomesticImage(file, requiredSquare);
+  assert.equal(result.status, 'rejected');
+  assert.match(result.violations[0], /比例/);
+}));
+
+test('recommended dimensions and alpha produce warnings without rejection', () => withFile(fakePng(1024, 1024, 6), file => {
+  const result = validateDomesticImage(file, requiredSquare);
+  assert.equal(result.status, 'warning');
+  assert.equal(result.passed, true);
+  assert.equal(result.warnings.length, 2);
+}));
+
+test('legacy items bypass domestic image validation', () => withFile(Buffer.from('not-an-image'), file => {
+  assert.equal(validateDomesticImage(file, null), null);
+}));

+ 4 - 6
claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.mjs

@@ -3,7 +3,7 @@ import { resolveImageInputs } from '../../core/attachment-resolver.mjs';
 import { errorResult, okResult } from '../../core/result-envelope.mjs';
 import { safeError } from '../../core/redaction.mjs';
 import { ProviderError } from '../../providers/provider-error.mjs';
-import { analysisReserveCny, COST_UNIT, NewApiImageProvider } from '../../providers/new-api-image.mjs';
+import { NewApiImageProvider } from '../../providers/new-api-image.mjs';
 
 const confidence = z.number().min(0).max(1).catch(0.5);
 const fact = z.object({
@@ -20,11 +20,11 @@ export const productAnalysisSchema = z.object({
   visual_inferences: z.array(inference).catch([]),
   unknowns: z.array(z.string()).catch([]),
   product_identity: z.object({
-    category: z.string().catch(''), shape: z.string().catch(''),
+    product_name: z.string().catch(''), category: z.string().catch(''), shape: z.string().catch(''),
     packaging: z.string().catch(''), logo: z.string().catch(''),
     visible_text: z.array(z.string()).catch([]), materials: z.array(z.string()).catch([]),
     colors: z.array(z.string()).catch([]), distinctive_features: z.array(z.string()).catch([])
-  }).catch({ category: '', shape: '', packaging: '', logo: '', visible_text: [], materials: [], colors: [], distinctive_features: [] }),
+  }).catch({ product_name: '', category: '', shape: '', packaging: '', logo: '', visible_text: [], materials: [], colors: [], distinctive_features: [] }),
   brand_style: z.object({
     palette: z.array(z.string()).catch([]), lighting: z.string().catch(''),
     mood: z.string().catch(''), composition: z.string().catch('')
@@ -80,9 +80,7 @@ export async function analyzeProductImages(input = {}, dependencies = {}) {
         visibleFactCount: analysis.visible_facts.length,
         inferenceCount: analysis.visual_inferences.length,
         unknownCount: analysis.unknowns.length,
-        model: result.model,
-        estimatedCostCny: analysisReserveCny(),
-        costUnit: COST_UNIT
+        model: result.model
       },
       data: { analysis, imageSources: images.map(({ label, source }) => ({ label, source })), referenceRoles, usage: result.usage, finishReason: result.finishReason },
       nextActions: ['审核商品事实;确认后调用 fmode_image_set_plan 生成无费用的套图计划。']

+ 2 - 1
claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.test.mjs

@@ -29,7 +29,7 @@ test('analysis validates structured Gemini result', async () => {
         analysis: {
           visible_facts: [{ field: 'color', value: 'red', source_image: 1, confidence: 0.9 }],
           visual_inferences: [], unknowns: ['size'],
-          product_identity: { category: 'beauty', shape: 'bottle', packaging: 'box', logo: '', visible_text: [], materials: [], colors: ['red'], distinctive_features: [] },
+          product_identity: { product_name: '红瓶精华', category: 'beauty', shape: 'bottle', packaging: 'box', logo: '', visible_text: [], materials: [], colors: ['red'], distinctive_features: [] },
           brand_style: { palette: ['#ff0000'], lighting: 'soft', mood: 'clean', composition: 'centered' },
           quality_risks: [], recommended_reference_roles: [{ image: 1, role: 'identity' }]
         }
@@ -38,6 +38,7 @@ test('analysis validates structured Gemini result', async () => {
     const result = await analyzeProductImages({ imagePath: fixture, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { provider, workspaceRoot: workspace });
     assert.equal(result.status, 'ok');
     assert.equal(result.data.analysis.visible_facts[0].value, 'red');
+    assert.equal(result.data.analysis.product_identity.product_name, '红瓶精华');
   } finally {
     fs.rmSync(workspace, { recursive: true, force: true });
   }

+ 119 - 0
claude-code/fmode-image-set/mcp/src/features/planning/domestic-platforms.mjs

@@ -0,0 +1,119 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { catalogRoot } from './catalog.mjs';
+
+const DOMESTIC_PRESETS = new Set(['compact', 'full', 'main-image', 'detail-page']);
+
+const OUTPUT_ASSET_TYPES = Object.freeze({
+  A1: 'white_background', A2: 'selling_point', A3: 'listing_secondary', A4: 'listing_secondary',
+  A5: 'scene', A6: 'listing_secondary', A7: 'package', A8: 'dimension'
+});
+
+const TEMPLATE_ASSET_TYPES = Object.freeze({
+  'hero-image': 'listing_primary',
+  'infographic': 'selling_point',
+  'multi-angle-grid': 'listing_secondary',
+  'detail-macro': 'listing_secondary',
+  'lifestyle-scene': 'scene',
+  'ugc-style': 'scene',
+  'model-showcase': 'scene',
+  'multi-product': 'listing_secondary',
+  packaging: 'package',
+  'size-spec': 'dimension',
+  'poster-banner': 'detail_module',
+  'before-after': 'detail_module',
+  'exploded-view': 'detail_module',
+  'luxury-atmospherics': 'detail_module',
+  'social-media': 'detail_module'
+});
+
+function readCatalog() {
+  return JSON.parse(fs.readFileSync(path.join(catalogRoot, 'domestic-platforms.json'), 'utf8').replace(/^\uFEFF/, ''));
+}
+
+function normalize(value) {
+  return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+const DOMESTIC_ALIAS_HINTS = new Set([
+  '淘宝/天猫', '淘宝天猫', '淘宝', '天猫', 'taobao', 'tmall', 'taobao.com', 'tmall.com',
+  '京东', 'jd', 'jd.com', 'jingdong', 'jing dong',
+  '拼多多', 'pdd', 'pinduoduo', 'pinduoduo.com'
+].map(normalize));
+
+function mergeRule(base, override = {}) {
+  return {
+    ...base,
+    ...override,
+    target: { ...(base.target || {}), ...(override.target || {}) },
+    enforcement: { ...(base.enforcement || {}), ...(override.enforcement || {}) },
+    warnings: [...new Set([...(base.warnings || []), ...(override.warnings || [])])]
+  };
+}
+
+export function loadDomesticPlatforms() {
+  return readCatalog();
+}
+
+export function resolveDomesticPlatform(value) {
+  const catalog = readCatalog();
+  const candidate = normalize(value);
+  if (!candidate) return null;
+  for (const [key, platform] of Object.entries(catalog.platforms || {})) {
+    const aliases = [key, ...(platform.aliases || [])].map(normalize);
+    if (aliases.includes(candidate)) return { key, ...structuredClone(platform) };
+  }
+  return null;
+}
+
+export function assetTypeForPlanItem({ outputId, templateId }) {
+  return OUTPUT_ASSET_TYPES[outputId] || TEMPLATE_ASSET_TYPES[templateId] || 'listing_secondary';
+}
+
+export function resolveDomesticAssetRule({ platform, preset, outputId, templateId }) {
+  if (!DOMESTIC_PRESETS.has(preset)) return null;
+  if (!DOMESTIC_ALIAS_HINTS.has(normalize(platform))) return null;
+  const catalog = readCatalog();
+  const candidate = normalize(platform);
+  const entry = Object.entries(catalog.platforms || {}).find(([key, value]) => (
+    [key, ...(value.aliases || [])].map(normalize).includes(candidate)
+  ));
+  const resolved = entry ? { key: entry[0], ...structuredClone(entry[1]) } : null;
+  if (!resolved) return null;
+  const assetType = assetTypeForPlanItem({ outputId, templateId });
+  const platformRule = catalog.platforms[resolved.key];
+  const withAssetDefaults = mergeRule(catalog.defaults, catalog.assetDefaults?.[assetType]);
+  const merged = mergeRule(withAssetDefaults, platformRule.assets?.[assetType]);
+  const evidence = (merged.evidenceIds || platformRule.evidenceIds || [])
+    .map(id => ({ id, ...catalog.evidence[id] }))
+    .filter(item => item.title);
+  return {
+    domestic: true,
+    schemaVersion: catalog.schemaVersion,
+    platformKey: resolved.key,
+    platformLabel: resolved.label,
+    assetType,
+    uploadRole: merged.uploadRole || `${resolved.label} ${assetType} 候选`,
+    target: merged.target,
+    enforcement: merged.enforcement,
+    verificationStatus: merged.verificationStatus,
+    evidence,
+    warnings: merged.warnings,
+    ruleCenterUrl: resolved.ruleCenterUrl,
+    complianceClaim: merged.verificationStatus === 'official_verified' ? 'official_verified' : 'not_guaranteed'
+  };
+}
+
+export function domesticPromptConstraints(rule) {
+  if (!rule?.domestic) return '';
+  const target = rule.target || {};
+  return [
+    `DOMESTIC MARKETPLACE TARGET — ${rule.platformLabel}; asset role: ${rule.uploadRole}.`,
+    `Compose for ${target.ratio || 'the requested'} ratio with safe zone: ${target.safeZone || 'keep the product fully visible'}.`,
+    `Background policy: ${target.background || 'follow the asset role'}.`,
+    `Text policy: ${target.textPolicy || 'use only caller-confirmed factual copy'}.`,
+    'This is a design target, not a claim that marketplace review is guaranteed.'
+  ].join(' ');
+}
+
+export { DOMESTIC_PRESETS };

+ 58 - 0
claude-code/fmode-image-set/mcp/src/features/planning/domestic-platforms.test.mjs

@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+  assetTypeForPlanItem, loadDomesticPlatforms, resolveDomesticAssetRule, resolveDomesticPlatform
+} from './domestic-platforms.mjs';
+
+test('domestic aliases resolve without changing unknown platforms', () => {
+  assert.equal(resolveDomesticPlatform('淘宝').key, 'taobao');
+  assert.equal(resolveDomesticPlatform('淘宝/天猫').key, 'taobao');
+  assert.equal(resolveDomesticPlatform('TMALL').key, 'taobao');
+  assert.equal(resolveDomesticPlatform('JD.com').key, 'jingdong');
+  assert.equal(resolveDomesticPlatform('PDD').key, 'pinduoduo');
+  assert.equal(resolveDomesticPlatform('Amazon'), null);
+});
+
+test('domestic catalog keeps evidence status explicit', () => {
+  const catalog = loadDomesticPlatforms();
+  assert.equal(catalog.evidence['TB-2024-01-26-MAIN-RATIO'].verificationStatus, 'official_verified');
+  assert.equal(catalog.evidence['JD-RULE-CENTER'].verificationStatus, 'official_entry_only');
+  assert.equal(catalog.evidence['PDD-HELP-CENTER'].verificationStatus, 'official_entry_only');
+  assert.equal(catalog.defaults.enforcement.textPolicy, 'recommended');
+  for (const [key, platform] of Object.entries(catalog.platforms)) {
+    for (const alias of [key, ...platform.aliases]) {
+      assert.equal(resolveDomesticAssetRule({ platform: alias, preset: 'compact', outputId: 'H1', templateId: 'hero-image' })?.platformKey, key);
+    }
+  }
+});
+
+test('main-image slots map to stable domestic asset types', () => {
+  assert.equal(assetTypeForPlanItem({ outputId: 'A1', templateId: 'hero-image' }), 'white_background');
+  assert.equal(assetTypeForPlanItem({ outputId: 'A2', templateId: 'infographic' }), 'selling_point');
+  assert.equal(assetTypeForPlanItem({ outputId: 'A8', templateId: 'size-spec' }), 'dimension');
+});
+
+test('only commerce presets receive domestic rules', () => {
+  const rule = resolveDomesticAssetRule({ platform: '淘宝', preset: 'main-image', outputId: 'A1', templateId: 'hero-image' });
+  assert.equal(rule.platformKey, 'taobao');
+  assert.equal(rule.enforcement.ratio, 'required');
+  assert.equal(rule.complianceClaim, 'not_guaranteed');
+  assert.equal(resolveDomesticAssetRule({ platform: '淘宝', preset: 'social', outputId: 'C1', templateId: 'social-media' }), null);
+});
+
+test('Taobao main-image evidence does not leak into unrelated asset types', () => {
+  const primary = resolveDomesticAssetRule({ platform: '淘宝', preset: 'compact', outputId: 'H1', templateId: 'hero-image' });
+  const sellingPoint = resolveDomesticAssetRule({ platform: '淘宝', preset: 'main-image', outputId: 'A2', templateId: 'infographic' });
+  assert.deepEqual(primary.evidence.map(item => item.id), ['TB-2024-01-26-MAIN-RATIO']);
+  assert.equal(primary.verificationStatus, 'official_verified_partial');
+  assert.deepEqual(sellingPoint.evidence, []);
+  assert.equal(sellingPoint.verificationStatus, 'safe_fallback');
+});
+
+test('non-primary assets keep their preset dimensions instead of inheriting a square target', () => {
+  const rule = resolveDomesticAssetRule({ platform: '京东', preset: 'detail-page', outputId: 'D1', templateId: 'poster-banner' });
+  assert.equal(rule.assetType, 'detail_module');
+  assert.equal(rule.target.width, null);
+  assert.equal(rule.target.height, null);
+  assert.equal(rule.target.ratio, null);
+});

+ 81 - 5
claude-code/fmode-image-set/mcp/src/features/planning/planner.mjs

@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
 import { COST_UNIT, GEMINI_IMAGE_MODEL, generationReserveCny, SEEDREAM_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
 import { sha256, stableStringify } from '../../core/request-key.mjs';
 import { loadPlatforms, loadTemplate } from './catalog.mjs';
+import { domesticPromptConstraints, resolveDomesticAssetRule } from './domestic-platforms.mjs';
 import { buildIdentityLock, buildStyleLock, NEGATIVE_CONSTRAINTS } from './locks.mjs';
 
 const BANNED_PROMPT_TERMS = /\b(instagram|tiktok|facebook|rednote|xiaohongshu|pinterest|linkedin|twitter|google display|amazon|shopify|sponsored|like button|follow button|comment box|engagement metrics|timestamp badge|audio icon|app ui|platform ui)\b/gi;
@@ -78,6 +79,20 @@ export function sanitizeGeneratorPrompt(value) {
   return String(value || '').replace(BANNED_PROMPT_TERMS, '').replace(/\s{2,}/g, ' ').replace(/\s+([,;:.])/g, '$1').trim();
 }
 
+export function deriveProductName(input = {}, analysis = {}) {
+  const explicit = String(input.productName || '').trim();
+  if (explicit) return explicit;
+  const identity = analysis.product_identity || {};
+  const identityName = String(identity.product_name || identity.productName || identity.name || '').trim();
+  if (identityName) return identityName;
+  const namedFact = (analysis.visible_facts || []).find(fact => /^(商品名|产品名|品名|product[ _-]?name|name)$/i.test(String(fact?.field || '').trim()));
+  if (String(namedFact?.value || '').trim()) return String(namedFact.value).trim();
+  const brand = String(identity.logo || '').trim();
+  const category = String(identity.category || '').trim();
+  if (brand && category && !category.toLowerCase().includes(brand.toLowerCase())) return `${brand}-${category}`;
+  return brand || category || '未命名商品';
+}
+
 function applyDimensions(item, width, height, ratio) {
   item.width = width;
   item.height = height;
@@ -117,6 +132,30 @@ function itemFromTuple(tuple, index, input, analysis, locale, platformOverride)
   const providerModel = input.modelPreference === 'seedream' || input.modelPreference === SEEDREAM_IMAGE_MODEL
     ? SEEDREAM_IMAGE_MODEL
     : GEMINI_IMAGE_MODEL;
+  const domesticRule = resolveDomesticAssetRule({
+    platform: input.platform, preset: input.preset, outputId, templateId
+  });
+  if (domesticRule) {
+    const target = domesticRule.target || {};
+    const domesticPlatform = {
+      ...platform,
+      label: domesticRule.platformLabel,
+      width: Number(target.width || platform.width),
+      height: Number(target.height || platform.height),
+      ratio: target.ratio || platform.ratio,
+      safe_zone: target.safeZone || platform.safe_zone,
+      text_policy: target.textPolicy || platform.text_policy
+    };
+    const basePrompt = templatePrompt(template, input, note ? `${title};${note}` : title, analysis, domesticPlatform);
+    return {
+      outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
+      templateId, templateName: template.name, width: domesticPlatform.width, height: domesticPlatform.height,
+      ratio: domesticPlatform.ratio, safeZone: domesticPlatform.safe_zone, textPolicy: domesticPlatform.text_policy,
+      providerModel, prompt: `${basePrompt} ${domesticPromptConstraints(domesticRule)}`,
+      estimatedCostCny: generationReserveCny(providerModel, { qualityReview: input.qualityReview !== false }), costUnit: COST_UNIT, sortOrder: index,
+      platformRule: domesticRule
+    };
+  }
   return {
     outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
     templateId, templateName: template.name, width: platform.width, height: platform.height,
@@ -148,9 +187,10 @@ export function buildImageSetPlan(input = {}) {
   const locale = input.locale === 'en' ? 'en' : 'zh';
   const preset = detectPreset(request, input.preset);
   const analysis = input.analysis?.analysis || input.analysis || {};
+  const productName = deriveProductName(input, analysis);
   const tuples = tuplesForPreset(preset, { ...input, request });
   if (!tuples.length || tuples.length > 14) throw new Error('输出计划必须包含 1–14 个项目。');
-  const items = tuples.map((tuple, index) => itemFromTuple(tuple, index, { ...input, request }, analysis, locale, tuple[4]));
+  const items = tuples.map((tuple, index) => itemFromTuple(tuple, index, { ...input, request, preset }, analysis, locale, tuple[4]));
   if (preset === 'multi-angle') {
     for (const item of items) applyDimensions(item, 2048, 2560, '4:5');
   }
@@ -161,7 +201,7 @@ export function buildImageSetPlan(input = {}) {
   const estimatedCostCny = Number(items.reduce((sum, item) => sum + (item.estimatedCostCny || 0), 0).toFixed(4));
   const planCore = {
     schemaVersion: 3, locale, preset, request, platform: input.platform || 'general_ecommerce',
-    productDescription: input.productDescription || '', analysis,
+    productName, productDescription: input.productDescription || '', analysis,
     items, estimatedCostCny, costUnit: COST_UNIT, costBasis: 'trusted_usd_price_and_exchange_rate', qualityReview: input.qualityReview !== false, requiresConfirmation: true,
     qualityChecks: ['商品身份一致', '品牌色与商品色一致', 'Logo/文字不变形', '平台尺寸和安全区', '无虚构声明', '无平台 UI/账号/水印', '无明显生成缺陷']
   };
@@ -175,12 +215,48 @@ export function buildImageSetPlan(input = {}) {
   };
 }
 
+export function toPublicPlan(plan = {}) {
+  const items = (plan.items || []).map(({ estimatedCostCny, costUnit, ...item }) => item);
+  const {
+    estimatedCostCny, costUnit, costBasis, confirmationMessage,
+    ...publicPlan
+  } = plan;
+  return {
+    ...publicPlan,
+    items,
+    confirmationMessage: `计划共 ${items.length} 张;确认张数、模型、尺寸、用途和提示词后即可生成。`
+  };
+}
+
+function restoreTrustedPlanCosts(plan = {}) {
+  const qualityReview = plan.qualityReview !== false;
+  const items = (plan.items || []).map(item => ({
+    ...item,
+    estimatedCostCny: generationReserveCny(item.providerModel, { qualityReview }),
+    costUnit: COST_UNIT
+  }));
+  return {
+    ...plan,
+    items,
+    estimatedCostCny: Number(items.reduce((sum, item) => sum + item.estimatedCostCny, 0).toFixed(4)),
+    costUnit: COST_UNIT,
+    costBasis: 'trusted_usd_price_and_exchange_rate'
+  };
+}
+
 export function verifyPlan(plan) {
   const { planId, planHash, createdAt, confirmationMessage, ...planCore } = plan || {};
-  const actual = sha256(stableStringify(planCore));
+  let verified = plan;
+  let actual = sha256(stableStringify(planCore));
+  if (planHash && actual !== planHash) {
+    const restored = restoreTrustedPlanCosts(plan);
+    const { planId: ignoredPlanId, planHash: ignoredPlanHash, createdAt: ignoredCreatedAt, confirmationMessage: ignoredMessage, ...restoredCore } = restored;
+    actual = sha256(stableStringify(restoredCore));
+    if (actual === planHash) verified = restored;
+  }
   if (!planHash || actual !== planHash) throw new Error('计划完整性校验失败,请重新规划。');
-  if (!planId || !Array.isArray(plan.items) || !plan.items.length || plan.items.length > 14) throw new Error('计划结构无效。');
-  return plan;
+  if (!planId || !Array.isArray(verified.items) || !verified.items.length || verified.items.length > 14) throw new Error('计划结构无效。');
+  return verified;
 }
 
 export { detectPreset, platformKey };

+ 61 - 1
claude-code/fmode-image-set/mcp/src/features/planning/planner.test.mjs

@@ -1,7 +1,8 @@
 import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
 import test from 'node:test';
 import { GEMINI_IMAGE_MODEL, SEEDREAM_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
-import { buildImageSetPlan, sanitizeGeneratorPrompt, verifyPlan } from './planner.mjs';
+import { buildImageSetPlan, deriveProductName, sanitizeGeneratorPrompt, toPublicPlan, verifyPlan } from './planner.mjs';
 
 test('compact high-quality plan uses Gemini and CNY reserve', () => {
   const plan = buildImageSetPlan({ request: '为这款护肤品规划一套图', productDescription: '磨砂玻璃精华瓶' });
@@ -14,6 +15,13 @@ test('compact high-quality plan uses Gemini and CNY reserve', () => {
   assert.equal(verifyPlan(plan), plan);
 });
 
+test('public plan omits user-visible cost data but preserves trusted plan verification', () => {
+  const plan = buildImageSetPlan({ request: '公开计划不显示费用' });
+  const publicPlan = toPublicPlan(plan);
+  assert.equal(verifyPlan(publicPlan).planHash, plan.planHash);
+  assert.doesNotMatch(JSON.stringify(publicPlan), /estimatedCostCny|costUnit|costBasis|¥|预计费用|预算/);
+});
+
 test('full preset preserves H1-H5 M1-M9 order and recalculated reserve', () => {
   const plan = buildImageSetPlan({ request: '做完整十四图', preset: 'full' });
   assert.deepEqual(plan.items.map(item => item.outputId), ['H1','H2','H3','H4','H5','M1','M2','M3','M4','M5','M6','M7','M8','M9']);
@@ -36,6 +44,17 @@ test('tampered plan is rejected', () => {
   assert.throws(() => verifyPlan(plan), /完整性/);
 });
 
+test('product name prefers user claim then verified analysis fields', () => {
+  const analysis = {
+    visible_facts: [{ field: '商品名', value: '识图咖啡' }],
+    product_identity: { logo: 'MORI', category: '咖啡豆' }
+  };
+  assert.equal(deriveProductName({ productName: '用户命名咖啡' }, analysis), '用户命名咖啡');
+  assert.equal(deriveProductName({}, analysis), '识图咖啡');
+  assert.equal(deriveProductName({}, { product_identity: { logo: 'MORI', category: '咖啡豆' } }), 'MORI-咖啡豆');
+  assert.equal(buildImageSetPlan({ request: '做七图', productName: '用户商品' }).productName, '用户商品');
+});
+
 test('detail-page wide modules keep prompt and dimensions in sync', () => {
   const plan = buildImageSetPlan({ request: '做 A+ 详情页', preset: 'detail-page' });
   assert.equal(plan.items[0].width, 3024);
@@ -51,3 +70,44 @@ test('all presets produce bounded executable plans', () => {
     verifyPlan(plan);
   }
 });
+
+test('domestic commerce plans carry isolated platform rules', () => {
+  const taobao = buildImageSetPlan({ request: '淘宝商品主图', preset: 'main-image', platform: '淘宝' });
+  assert.equal(taobao.items[0].platformRule.platformKey, 'taobao');
+  assert.equal(taobao.items[0].platformRule.assetType, 'white_background');
+  assert.equal(taobao.items[0].platformRule.enforcement.ratio, 'required');
+  assert.match(taobao.items[0].prompt, /DOMESTIC MARKETPLACE TARGET/);
+  assert.equal(verifyPlan(taobao), taobao);
+
+  const jd = buildImageSetPlan({ request: '京东商品主图', preset: 'main-image', platform: 'JD' });
+  assert.equal(jd.items[0].platformRule.platformKey, 'jingdong');
+  assert.equal(jd.items[0].platformRule.verificationStatus, 'safe_fallback');
+
+  const pdd = buildImageSetPlan({ request: '拼多多商品主图', preset: 'main-image', platform: 'PDD' });
+  assert.equal(pdd.items[0].platformRule.platformKey, 'pinduoduo');
+  assert.equal(pdd.items[0].platformRule.complianceClaim, 'not_guaranteed');
+
+  const detail = buildImageSetPlan({ request: '京东商品详情页', preset: 'detail-page', platform: '京东' });
+  assert.deepEqual([detail.items[0].width, detail.items[0].height, detail.items[0].ratio], [3024, 1296, '21:9']);
+  assert.equal(detail.items[0].platformRule.target.ratio, null);
+  assert.match(detail.items[0].prompt, /Canvas 3024x1296/);
+});
+
+test('legacy 12-platform x 7-preset plans remain byte-for-byte compatible', () => {
+  const platforms = ['general_ecommerce', 'amazon', 'shopify', 'instagram_feed', 'instagram_story', 'tiktok', 'facebook_ad', 'google_display', 'linkedin_ad', 'pinterest', 'rednote', 'x'];
+  const presets = ['compact', 'full', 'main-image', 'detail-page', 'multi-angle', 'ad-creative', 'social'];
+  const snapshots = [];
+  for (const platform of platforms) for (const preset of presets) {
+    const plan = buildImageSetPlan({
+      request: `baseline ${preset}`,
+      preset,
+      platform,
+      platforms: [platform],
+      productName: 'baseline-product',
+      createdAt: '2026-08-11T00:00:00.000Z'
+    });
+    snapshots.push({ platform, preset, plan });
+  }
+  const hash = createHash('sha256').update(`${JSON.stringify(snapshots, null, 2)}\n`).digest('hex');
+  assert.equal(hash, 'c6c2c7e1a526703a392c2356ff86f0cd6e69fd0cb4e4c467451a2a7d16cdb368');
+});

+ 32 - 15
claude-code/fmode-image-set/mcp/src/providers/new-api-image.mjs

@@ -55,7 +55,7 @@ Respond in ${locale} with this schema:
   "visible_facts": [{"field":"","value":"","source_image":1,"confidence":0.0}],
   "visual_inferences": [{"field":"","value":"","confidence":0.0,"reason":""}],
   "unknowns": [""],
-  "product_identity": {"category":"","shape":"","packaging":"","logo":"","visible_text":[],"materials":[],"colors":[],"distinctive_features":[]},
+  "product_identity": {"product_name":"","category":"","shape":"","packaging":"","logo":"","visible_text":[],"materials":[],"colors":[],"distinctive_features":[]},
   "brand_style": {"palette":[],"lighting":"","mood":"","composition":""},
   "quality_risks": [""],
   "recommended_reference_roles": [{"image":1,"role":"identity|style|detail|model"}]
@@ -63,9 +63,18 @@ Respond in ${locale} with this schema:
 }
 
 export function geminiQualityPrompt(input = {}) {
+  const platformSection = input.platformRule?.domestic
+    ? `\nDomestic marketplace design target: ${JSON.stringify({
+      platform: input.platformRule.platformLabel,
+      assetType: input.platformRule.assetType,
+      uploadRole: input.platformRule.uploadRole,
+      target: input.platformRule.target,
+      verificationStatus: input.platformRule.verificationStatus
+    })}\nAlso report a platform_constraints boolean in checks. Treat an explicit required constraint as a rejection criterion; treat recommended or unverified constraints as recommendations, not guaranteed platform compliance.`
+    : '';
   return `Review one generated product image against the preceding original reference images.
 Original reference image count: ${Number(input.originalCount || 0)}. The final image is the generated candidate.
-Expected product identity: ${JSON.stringify(input.expectedIdentity || {})}
+Expected product identity: ${JSON.stringify(input.expectedIdentity || {})}${platformSection}
 Return one JSON object only:
 {"passed":true,"score":0.0,"checks":{"identity":true,"color":true,"logo_text":true,"composition":true,"defects":true},"violations":[],"recommendations":[]}
 Set passed=false for product identity drift, material/color drift, malformed logo/text, severe composition failure, or obvious generation defects.`;
@@ -143,7 +152,7 @@ export class NewApiImageProvider {
     this.fetch = options.fetchImpl || globalThis.fetch;
   }
 
-  async request(endpoint, body, { submission = false } = {}) {
+  async request(endpoint, body, { submission = false, idempotencyKey = '' } = {}) {
     if (!this.config.apiKey) throw new ProviderError('needs_token', '未配置 FMODE_LLM_API_KEY 或 LLM_API_KEY。');
     const baseUrl = ensureEndpoint(this.config);
     const controller = new AbortController();
@@ -152,26 +161,29 @@ export class NewApiImageProvider {
     try {
       response = await this.fetch(`${baseUrl}${endpoint}`, {
         method: 'POST',
-        headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: asBearerToken(this.config.apiKey) },
+        headers: {
+          Accept: 'application/json', 'Content-Type': 'application/json', Authorization: asBearerToken(this.config.apiKey),
+          ...(idempotencyKey ? { 'Idempotency-Key': String(idempotencyKey) } : {})
+        },
         signal: controller.signal,
         body: JSON.stringify(body)
       });
     } catch (error) {
       const timeout = error?.name === 'AbortError';
       throw new ProviderError(submission ? 'submission_unknown' : 'provider_unavailable', submission
-        ? `生图${timeout ? '超时' : '连接中断'},服务端是否已计费未知;禁止自动重提。`
+        ? `生图${timeout ? '超时' : '连接中断'},请求是否已被上游接收未知。`
         : `Gemini 请求${timeout ? '超时' : '暂时无法连接'}。`);
     } finally { clearTimeout(timer); }
     if (!response.ok) {
       if (submission && (response.status === 408 || response.status === 429 || response.status >= 500)) {
-        throw new ProviderError('submission_unknown', '生图网关返回暂时性异常,请求是否已被上游接收或计费未知;禁止自动重提。', { httpStatus: response.status });
+        throw new ProviderError('submission_unknown', '生图网关返回暂时性异常,请求是否已被上游接收未知。', { httpStatus: response.status });
       }
       throw new ProviderError(mapHttpStatus(response.status), 'New API 返回异常;鉴权和上游详情已隐藏。', { httpStatus: response.status });
     }
     try { return await response.json(); }
     catch {
       throw new ProviderError(submission ? 'submission_unknown' : 'error', submission
-        ? '生图响应无法解析,服务端是否已计费未知;禁止自动重提。'
+        ? '生图响应无法解析,请求状态未知。'
         : 'Gemini 响应无法解析。');
     }
   }
@@ -210,16 +222,16 @@ export class NewApiImageProvider {
       json = await this.request('/v1/images/generations', {
         model, prompt: item.prompt, ...(references.length ? { image: references } : {}), size: '2K',
         sequential_image_generation: 'disabled', stream: false, response_format: 'b64_json', watermark: false
-      }, { submission: true });
+      }, { submission: true, idempotencyKey: item.idempotencyKey });
       images = seedreamImages(json);
     } else {
       json = await this.request('/v1/chat/completions', {
         model, modalities: ['text', 'image'],
         messages: [{ role: 'user', content: [...imageContent(references), { type: 'text', text: item.prompt }] }]
-      }, { submission: true });
+      }, { submission: true, idempotencyKey: item.idempotencyKey });
       images = extractGeminiImages(json);
     }
-    if (!images.length) throw new ProviderError('submission_unknown', '生图响应中没有可接收的图片;服务端是否已计费未知,禁止自动重提。');
+    if (!images.length) throw new ProviderError('submission_unknown', '生图响应中没有可接收的图片,请求状态未知。');
     return {
       requestId: String(json?.id || json?.request_id || ''), model, images, usage: json?.usage || {},
       estimatedCostCny: generationReserveCny(model, { qualityReview: false }), costUnit: COST_UNIT
@@ -233,11 +245,16 @@ export class NewApiImageProvider {
     if (inline) ({ mime, bytes } = inline);
     else if (/^https?:\/\//i.test(String(value || ''))) {
       let response;
-      try { response = await this.fetch(value); }
-      catch { throw new ProviderError('provider_unavailable', '生成图片下载失败。'); }
-      if (!response.ok) throw new ProviderError('provider_unavailable', '生成图片下载失败。', { httpStatus: response.status });
-      mime = response.headers?.get?.('content-type') || mime;
-      bytes = Buffer.from(await response.arrayBuffer());
+      try {
+        response = await this.fetch(value);
+        if (!response.ok) throw new ProviderError('provider_unavailable', '生成图片下载失败。', { httpStatus: response.status });
+        mime = response.headers?.get?.('content-type') || mime;
+        bytes = Buffer.from(await response.arrayBuffer());
+        if (!bytes.length) throw new ProviderError('provider_unavailable', '生成图片下载结果为空。');
+      } catch (error) {
+        if (error instanceof ProviderError) throw error;
+        throw new ProviderError('provider_unavailable', '生成图片下载失败。');
+      }
     } else throw new ProviderError('error', '生图返回了不支持的图片格式。');
     if (!bytes?.length || bytes.length > 40 * 1024 * 1024) throw new ProviderError('error', '生图文件为空或超过 40MB 上限。');
     const filePath = assertSafeFile(runDirectory, path.join(runDirectory, `${outputId}-${index + 1}${extensionFor(mime)}`));

+ 48 - 8
claude-code/fmode-image-set/mcp/src/providers/providers.test.mjs

@@ -4,7 +4,7 @@ import os from 'node:os';
 import path from 'node:path';
 import test from 'node:test';
 import {
-  analysisReserveCny, GEMINI_IMAGE_MODEL, generationReserveCny, NewApiImageProvider,
+  analysisReserveCny, GEMINI_IMAGE_MODEL, geminiQualityPrompt, generationReserveCny, NewApiImageProvider,
   pricingSnapshot, SEEDREAM_IMAGE_MODEL
 } from './new-api-image.mjs';
 import { ProviderError } from './provider-error.mjs';
@@ -13,6 +13,20 @@ const structuredAnalysis = {
   visible_facts: [], visual_inferences: [], unknowns: [], product_identity: {}, brand_style: {}, quality_risks: [], recommended_reference_roles: []
 };
 
+test('domestic quality prompt is additive and legacy prompt remains unchanged', () => {
+  const legacy = geminiQualityPrompt({ expectedIdentity: { category: 'bottle' }, originalCount: 1 });
+  assert.doesNotMatch(legacy, /Domestic marketplace/);
+  const domestic = geminiQualityPrompt({
+    expectedIdentity: { category: 'bottle' }, originalCount: 1,
+    platformRule: {
+      domestic: true, platformLabel: '淘宝/天猫', assetType: 'white_background', uploadRole: '白底商品图候选',
+      target: { ratio: '1:1' }, verificationStatus: 'official_verified_partial'
+    }
+  });
+  assert.match(domestic, /Domestic marketplace design target/);
+  assert.match(domestic, /platform_constraints/);
+});
+
 test('pricing converts trusted USD prices to CNY at gateway rate 6.8', () => {
   const pricing = pricingSnapshot();
   assert.equal(pricing.usdToCnyRate, 6.8);
@@ -52,15 +66,17 @@ test('Gemini provider returns structured generation quality review', async () =>
 });
 
 test('Gemini generation extracts markdown data URL', async () => {
-  let request;
+  let request; let requestHeaders;
   const fetchImpl = async (_url, init) => {
     request = JSON.parse(init.body);
+    requestHeaders = init.headers;
     return new Response(JSON.stringify({ id: 'gem-1', choices: [{ message: { content: '![image](data:image/png;base64,ZmFrZQ==)' } }] }), { status: 200 });
   };
   const provider = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test', fetchImpl });
-  const result = await provider.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'generate', referenceImages: ['data:image/png;base64,AAAA'] });
+  const result = await provider.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'generate', referenceImages: ['data:image/png;base64,AAAA'], idempotencyKey: 'stable-test-key' });
   assert.equal(result.images[0], 'data:image/png;base64,ZmFrZQ==');
   assert.deepEqual(request.modalities, ['text', 'image']);
+  assert.equal(requestHeaders['Idempotency-Key'], 'stable-test-key');
 });
 
 test('Seedream generation uses images endpoint and b64_json', async () => {
@@ -120,16 +136,23 @@ test('paid generation malformed JSON or successful response without image is amb
 });
 
 test('provider ignores caller endpoint field and rejects unapproved remote HTTP', async () => {
+  const previous = process.env.FMODE_ALLOW_INSECURE_HTTP;
+  process.env.FMODE_ALLOW_INSECURE_HTTP = 'false';
   let requestUrl = '';
   const fetchImpl = async url => {
     requestUrl = String(url);
     return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(structuredAnalysis) } }] }), { status: 200 });
   };
-  const safe = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://safe.test', baseUrl: 'https://attacker.invalid', fetchImpl });
-  await safe.analyze([{ url: 'data:image/png;base64,AAAA' }]);
-  assert.equal(requestUrl.startsWith('https://safe.test/'), true);
-  const insecure = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'http://gateway.test', fetchImpl });
-  await assert.rejects(() => insecure.analyze([{ url: 'data:image/png;base64,AAAA' }]), error => error instanceof ProviderError && error.kind === 'blocked');
+  try {
+    const safe = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://safe.test', baseUrl: 'https://attacker.invalid', fetchImpl });
+    await safe.analyze([{ url: 'data:image/png;base64,AAAA' }]);
+    assert.equal(requestUrl.startsWith('https://safe.test/'), true);
+    const insecure = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'http://gateway.test', fetchImpl });
+    await assert.rejects(() => insecure.analyze([{ url: 'data:image/png;base64,AAAA' }]), error => error instanceof ProviderError && error.kind === 'blocked');
+  } finally {
+    if (previous === undefined) delete process.env.FMODE_ALLOW_INSECURE_HTTP;
+    else process.env.FMODE_ALLOW_INSECURE_HTTP = previous;
+  }
 });
 
 test('trusted remote HTTP works only with explicit opt-in', async () => {
@@ -153,3 +176,20 @@ test('materialize decodes inline image without remote URL', async () => {
     assert.equal(fs.readFileSync(file, 'utf8'), 'fake');
   } finally { fs.rmSync(directory, { recursive: true, force: true }); }
 });
+
+test('remote response body interruption maps to retryable provider_unavailable', async () => {
+  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-materialize-body-'));
+  try {
+    const provider = new NewApiImageProvider({
+      apiKey: 'fake', trustedBaseUrl: 'https://gateway.test',
+      fetchImpl: async () => ({
+        ok: true, status: 200, headers: { get: () => 'image/png' },
+        arrayBuffer: async () => { throw new TypeError('body terminated'); }
+      })
+    });
+    await assert.rejects(
+      () => provider.materialize('https://result.test/image.png', directory, 'S1'),
+      error => error instanceof ProviderError && error.kind === 'provider_unavailable'
+    );
+  } finally { fs.rmSync(directory, { recursive: true, force: true }); }
+});

+ 15 - 12
claude-code/fmode-image-set/mcp/src/server.mjs

@@ -11,7 +11,7 @@ import { runStatus } from './tools/status.mjs';
 import { runTemplates } from './tools/templates.mjs';
 import { runUpscale } from './tools/upscale.mjs';
 
-export const VERSION = '0.2.1';
+export const VERSION = '0.4.0';
 export const TOOL_NAMES = [
   'fmode_image_set_analyze', 'fmode_image_set_plan', 'fmode_image_set_templates',
   'fmode_image_set_generate', 'fmode_image_set_status', 'fmode_image_set_edit', 'fmode_image_set_upscale'
@@ -25,12 +25,13 @@ const outputSchema = {
 const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
 const externalRead = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
 const paidWrite = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true };
+const paidGenerate = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true };
 
 export function createServer() {
   const server = new McpServer({ name: 'fmode-image-set', version: VERSION });
 
   server.registerTool('fmode_image_set_analyze', {
-    description: 'Analyze current product images with gemini-3.1-flash-image-preview. Separates visible facts, visual inferences, and unknowns.',
+    description: 'Analyze explicitly supplied current-project product image paths or current Claude attachments with Gemini. Separates visible facts, visual inferences, and unknowns.',
     inputSchema: {
       imagePath: z.string().optional(), imageUrl: z.string().optional(), images: z.array(z.unknown()).optional(),
       imageRoles: z.record(z.string(), z.string()).optional(),
@@ -41,10 +42,11 @@ export function createServer() {
   }, async input => asToolResult(await runAnalyze(input)));
 
   server.registerTool('fmode_image_set_plan', {
-    description: 'Create a no-generation-cost product image-set brief and output plan. Use before any paid generation.',
+    description: 'Create a product image-set brief with a user/analysis-derived productName. This tool does not generate images.',
     inputSchema: {
       request: z.string(), preset: z.enum(['compact', 'full', 'main-image', 'detail-page', 'multi-angle', 'ad-creative', 'social']).optional(),
       locale: z.enum(['zh', 'en']).optional(), analysis: z.unknown().optional(), productDescription: z.string().optional(),
+      productName: z.string().max(120).optional(),
       category: z.string().optional(), platform: z.string().optional(), platforms: z.array(z.string()).optional(),
       style: z.string().optional(), brandColors: z.array(z.string()).optional(), requestedCopy: z.string().optional(), count: z.number().int().min(1).max(14).optional(),
       modelPreference: z.enum(['high_quality', 'gemini', 'seedream']).optional(), qualityReview: z.boolean().optional()
@@ -52,18 +54,19 @@ export function createServer() {
   }, async input => asToolResult(await runPlan(input)));
 
   server.registerTool('fmode_image_set_templates', {
-    description: 'Search the local 25-scene template catalog and platform constraints. No external call or generation cost.',
+    description: 'Search the local 25-scene template catalog and platform constraints without generating images.',
     inputSchema: { query: z.string().optional(), locale: z.enum(['zh', 'en']).optional(), includePlatforms: z.boolean().optional() },
     outputSchema, annotations: readOnly
   }, async input => asToolResult(await runTemplates(input)));
 
   server.registerTool('fmode_image_set_generate', {
-    description: 'Execute a confirmed image-set plan through New API Gemini or Seedream. Paid action; requires confirmed=true plus a CNY budget limit.',
+    description: 'Execute a confirmed image-set plan, or retry only selected failed outputIds in an existing run. Transient single-image failures are retried automatically and do not block later outputs. On full success, root-level local references are moved and nested references are copied into the Chinese run directory.',
     inputSchema: {
-      plan: z.record(z.string(), z.unknown()), confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative(),
+      plan: z.record(z.string(), z.unknown()).optional(), runId: z.string().optional(),
+      retryOutputIds: z.array(z.string()).optional(), confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative().optional(),
       sessionId: z.string(), promptId: z.string(), referenceImages: z.array(z.unknown()).optional(),
       qualityReview: z.boolean().optional(), allowClaudeAttachments: z.boolean().optional()
-    }, outputSchema, annotations: paidWrite
+    }, outputSchema, annotations: paidGenerate
   }, async input => asToolResult(await runGenerate(input)));
 
   server.registerTool('fmode_image_set_status', {
@@ -73,20 +76,20 @@ export function createServer() {
   }, async input => asToolResult(await runStatus(input)));
 
   server.registerTool('fmode_image_set_edit', {
-    description: 'Edit a product image with Gemini or Seedream while preserving identity. Paid action; requires explicit confirmation and CNY budget.',
+    description: 'Edit a confirmed product image while preserving identity. A quality-rejected image from a managed image-set is revised in place with versioned history; standalone images keep an independent run.',
     inputSchema: {
       image: z.string(), maskUrl: z.string().optional(), prompt: z.string(), modelPreference: z.enum(['high_quality', 'gemini', 'seedream']).optional(),
       width: z.number().int().optional(), height: z.number().int().optional(), scale: z.number().optional(),
-      confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative(), sessionId: z.string(), promptId: z.string(),
+      confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative().optional(), sessionId: z.string(), promptId: z.string(),
       qualityReview: z.boolean().optional()
-    }, outputSchema, annotations: paidWrite
+    }, outputSchema, annotations: paidGenerate
   }, async input => asToolResult(await runEdit(input)));
 
   server.registerTool('fmode_image_set_upscale', {
-    description: 'Regenerate one local or remote image as a high-quality 2K Seedream output. Paid action; requires explicit confirmation and gateway-unit budget.',
+    description: 'Regenerate one confirmed local or remote image as a high-quality 2K Seedream output.',
     inputSchema: {
       image: z.string(), scale: z.number().min(0).max(100).optional(),
-      confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative(), sessionId: z.string(), promptId: z.string(),
+      confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative().optional(), sessionId: z.string(), promptId: z.string(),
       qualityReview: z.boolean().optional()
     }, outputSchema, annotations: paidWrite
   }, async input => asToolResult(await runUpscale(input)));

+ 5 - 2
claude-code/fmode-image-set/mcp/src/tools/edit.mjs

@@ -1,16 +1,19 @@
-import { buildOperationPlan, executePlan } from '../features/generation/orchestrator.mjs';
+import { buildOperationPlan, executePlan, executeSuiteRevision } from '../features/generation/orchestrator.mjs';
 import { GEMINI_IMAGE_MODEL, SEEDREAM_IMAGE_MODEL } from '../providers/new-api-image.mjs';
 
 export async function runEdit(input = {}, dependencies = {}) {
   const references = [input.image || input.imageUrl || input.imagePath, input.maskUrl].filter(Boolean);
   const providerModel = input.modelPreference === 'seedream' ? SEEDREAM_IMAGE_MODEL : GEMINI_IMAGE_MODEL;
+  const prompt = `${input.prompt || '增强图片。'} 在保持商品身份、包装、Logo、颜色和材质不变的前提下完成编辑。${input.maskUrl ? ' 第二张参考图是编辑区域遮罩,只修改遮罩指定区域。' : ''}`;
   const plan = buildOperationPlan({
     providerModel,
-    prompt: `${input.prompt || '增强图片。'} 在保持商品身份、包装、Logo、颜色和材质不变的前提下完成编辑。${input.maskUrl ? ' 第二张参考图是编辑区域遮罩,只修改遮罩指定区域。' : ''}`,
+    prompt,
     referenceImages: references,
     width: input.width || 2048,
     height: input.height || 2048,
     scale: input.scale
   });
+  const revision = await executeSuiteRevision({ ...input, plan, referenceImages: references }, dependencies);
+  if (revision) return revision;
   return executePlan({ ...input, plan, referenceImages: references }, dependencies);
 }

+ 5 - 2
claude-code/fmode-image-set/mcp/src/tools/generate.mjs

@@ -1,5 +1,8 @@
-import { executePlan } from '../features/generation/orchestrator.mjs';
+import { executePlan, retryRunItems } from '../features/generation/orchestrator.mjs';
 
 export async function runGenerate(input = {}, dependencies = {}) {
-  return executePlan(input, dependencies);
+  if (input.runId && (input.retryOutputIds || input.retryFailedOutputIds)) {
+    return retryRunItems(input, dependencies);
+  }
+  return executePlan({ ...input, organizeSources: true }, dependencies);
 }

+ 6 - 5
claude-code/fmode-image-set/mcp/src/tools/plan.mjs

@@ -1,15 +1,16 @@
 import { errorResult, okResult } from '../core/result-envelope.mjs';
 import { safeError } from '../core/redaction.mjs';
-import { buildImageSetPlan } from '../features/planning/planner.mjs';
+import { buildImageSetPlan, toPublicPlan } from '../features/planning/planner.mjs';
 
 export async function runPlan(input = {}) {
   try {
     const plan = buildImageSetPlan(input);
+    const publicPlan = toPublicPlan(plan);
     return okResult({
-      assistantMessage: plan.confirmationMessage,
-      summary: { planId: plan.planId, preset: plan.preset, outputs: plan.items.length, estimatedCostCny: plan.estimatedCostCny, costUnit: plan.costUnit, requiresConfirmation: true },
-      data: { plan },
-      nextActions: ['逐项审核目标、模型、尺寸和提示词。', '确 confirmed=true 与 budgetLimitCny(人民币)后调用 fmode_image_set_generate。']
+      assistantMessage: publicPlan.confirmationMessage,
+      summary: { planId: plan.planId, preset: plan.preset, outputs: plan.items.length, requiresConfirmation: true },
+      data: { plan: publicPlan },
+      nextActions: ['逐项审核目标、模型、尺寸和提示词。', '确认后以 confirmed=true 调用 fmode_image_set_generate。']
     });
   } catch (error) {
     return errorResult('needs_input', safeError(error));

+ 2 - 2
claude-code/fmode-image-set/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "fmode-image-set",
-  "version": "0.2.1",
+  "version": "0.4.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "fmode-image-set",
-      "version": "0.2.1",
+      "version": "0.4.0",
       "license": "MIT",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.29.0",

+ 1 - 1
claude-code/fmode-image-set/package.json

@@ -1,6 +1,6 @@
 {
   "name": "fmode-image-set",
-  "version": "0.2.1",
+  "version": "0.4.0",
   "description": "Claude Code product image-set planning and generation skills with New API Gemini and Seedream models.",
   "type": "module",
   "bin": {

+ 1 - 1
claude-code/fmode-image-set/scripts/installer-acceptance.mjs

@@ -17,7 +17,7 @@ try {
   if (!mcp.mcpServers.existing || !mcp.mcpServers['fmode-image-set']) throw new Error('.mcp.json merge failed');
   if (!path.isAbsolute(mcp.mcpServers['fmode-image-set'].cwd) || !fs.existsSync(mcp.mcpServers['fmode-image-set'].args[0])) throw new Error('MCP paths are invalid');
   if (mcp.mcpServers['fmode-image-set'].env?.FMODE_WORKSPACE_ROOT !== workspace) throw new Error('Workspace root env was not injected');
-  for (const forbidden of ['node_modules', '.env', 'outputs']) if (fs.existsSync(path.join(installed, forbidden))) throw new Error(`Forbidden copy: ${forbidden}`);
+  for (const forbidden of ['node_modules', '.env', 'outputs', '输出']) if (fs.existsSync(path.join(installed, forbidden))) throw new Error(`Forbidden copy: ${forbidden}`);
   for (const legacy of ['qwen-vision.mjs', 'jimeng.mjs']) {
     if (fs.existsSync(path.join(installed, 'mcp', 'src', 'providers', legacy))) throw new Error(`Legacy provider remained installed: ${legacy}`);
   }

+ 46 - 46
claude-code/fmode-image-set/skill-package-manifest.json

@@ -1,47 +1,47 @@
-{
-  "name": "fmode-image-set",
-  "version": "0.2.1",
-  "description": "Claude Code native product image-set planning, analysis, generation, editing, and recovery package.",
-  "plugin": "fmode-image-set",
-  "skills": [
-    "fmode-image-set",
-    "fmode-product-vision",
-    "fmode-product-main-image",
-    "fmode-detail-page",
-    "fmode-multi-angle",
-    "fmode-ad-creative",
-    "fmode-social-visual"
-  ],
-  "mcpTools": [
-    "fmode_image_set_analyze",
-    "fmode_image_set_plan",
-    "fmode_image_set_templates",
-    "fmode_image_set_generate",
-    "fmode_image_set_status",
-    "fmode_image_set_edit",
-    "fmode_image_set_upscale"
-  ],
-  "entrySkill": "fmode-image-set",
-  "capabilityMap": "docs/capability-map.md",
-  "customerQuickstart": "docs/customer-quickstart.md",
-  "liveAcceptanceChecklist": "docs/live-manual-acceptance-checklist.md",
-  "deliverableContract": "docs/deliverables/image-set-report.md",
-  "primaryUseCases": [
-    "Analyze product reference images with gemini-3.1-flash-image-preview",
-    "Plan compact seven-image or full H1-H5/M1-M9 product image sets",
-    "Generate main images, detail-page modules, multi-angle series, ads, and social visuals with Gemini or Seedream",
-    "Receive Base64 image results directly and report partial results safely",
-    "Edit images with Gemini or Seedream and create 2K Seedream enhancements"
-  ],
-  "recommendedUserPrompts": [
-    "分析这张商品图,先给我一套七图方案,不要直接生图。",
-    "按亚马逊主图和副图规范规划一套商品图,保持包装和颜色一致。",
-    "用高质量模型执行确认后的套图计划,人民币预算不超过 5.3 元。",
-    "根据这张模特图生成九个一致的服装展示角度。"
-  ],
-  "installCommand": "node install.js",
-  "workspaceInstallCommand": "fmode-image-set workspace --smoke",
-  "workspaceInstallTarget": ".claude/plugins/fmode-image-set",
-  "mcpSmokeCommand": "npm run mcp:smoke",
-  "packageSmokeCommand": "npm run smoke:package"
+{
+    "name":  "fmode-image-set",
+    "version":  "0.4.0",
+    "description":  "Claude Code native product image-set planning, analysis, generation, editing, and recovery package.",
+    "plugin":  "fmode-image-set",
+    "skills":  [
+                   "fmode-image-set",
+                   "fmode-product-vision",
+                   "fmode-product-main-image",
+                   "fmode-detail-page",
+                   "fmode-multi-angle",
+                   "fmode-ad-creative",
+                   "fmode-social-visual"
+               ],
+    "mcpTools":  [
+                     "fmode_image_set_analyze",
+                     "fmode_image_set_plan",
+                     "fmode_image_set_templates",
+                     "fmode_image_set_generate",
+                     "fmode_image_set_status",
+                     "fmode_image_set_edit",
+                     "fmode_image_set_upscale"
+                 ],
+    "entrySkill":  "fmode-image-set",
+    "capabilityMap":  "docs/capability-map.md",
+    "customerQuickstart":  "docs/customer-quickstart.md",
+    "liveAcceptanceChecklist":  "docs/live-manual-acceptance-checklist.md",
+    "deliverableContract":  "docs/deliverables/image-set-report.md",
+    "primaryUseCases":  [
+                            "Analyze product reference images with gemini-3.1-flash-image-preview",
+                            "Plan compact seven-image or full H1-H5/M1-M9 product image sets",
+                            "Generate main images, detail-page modules, multi-angle series, ads, and social visuals with Gemini or Seedream",
+                            "Receive Base64 image results directly and report partial results safely",
+                            "Edit images with Gemini or Seedream and create 2K Seedream enhancements"
+                        ],
+    "recommendedUserPrompts":  [
+                                   "D:\\项目\\商品正面.png 和 D:\\项目\\参考图\\商品背面.jpg 是同一商品的图片,先给我一套七图方案,不要直接生图。",
+                                   "按亚马逊主图和副图规范规划一套商品图,保持包装和颜色一致。",
+                                   "继续刚才的原任务,只重试失败的 outputId,不要重新规划或生成已完成图片。",
+                                   "根据这张模特图生成九个一致的服装展示角度。"
+                               ],
+    "installCommand":  "node install.js",
+    "workspaceInstallCommand":  "fmode-image-set workspace --smoke",
+    "workspaceInstallTarget":  ".claude/plugins/fmode-image-set",
+    "mcpSmokeCommand":  "npm run mcp:smoke",
+    "packageSmokeCommand":  "npm run smoke:package"
 }

+ 1 - 1
claude-code/fmode-image-set/skills/fmode-ad-creative/SKILL.md

@@ -10,4 +10,4 @@ description: Plan and generate paid-ad creative variants with platform-specific
 3. 调用 `fmode_image_set_plan`,设置 `preset=ad-creative` 并传入 `platforms`。
 4. 多平台必须分别构图,禁止只做简单缩放;Google Display 版本不在生成图中渲染文字。
 5. 生成提示词不得包含平台 UI、账号、互动指标或“Sponsored”等伪界面元素。
-6. 展示每个平台版本和总预算,确认后调用 `fmode_image_set_generate`
+6. 展示每个平台版本、尺寸和用途,不展示生成费用;确认后调用 `fmode_image_set_generate`。单项失败不阻断其他版本,结束后只按原 `runId + retryOutputIds` 补失败版本

+ 4 - 2
claude-code/fmode-image-set/skills/fmode-detail-page/SKILL.md

@@ -6,8 +6,10 @@ description: Plan and generate e-commerce detail-page and Amazon A+ modules with
 # Fmode Detail Page
 
 1. 先分析商品参考图和已确认主图;没有身份锚点时先阻断。
-2. 调用 `fmode_image_set_plan`,设置 `preset=detail-page`。
+2. 调用 `fmode_image_set_plan`,设置 `preset=detail-page`;淘宝/天猫、京东、拼多多详情请求显式传 `platform`,并展示每项 `platformRule` 的证据状态和安全降级警告
 3. 按 Hero、痛点、卖点、材质、技术、规格/用法、系列、品牌背书构建叙事。
 4. Hero 与收束模块使用宽幅,其余模块保持移动端安全区和可读性。
 5. 商品事实不足时不生成虚构卖点;AI 不擅长的长文案留给后期排版。
-6. 展示计划和预算,确认后调用 `fmode_image_set_generate`。
+6. 展示模块计划、尺寸和用途,不展示生成费用;确认后调用 `fmode_image_set_generate`。
+7. 国内平台详情模块当前没有官方已核验的通用像素数值时沿用原详情构图并明确提示商家后台复核,不能宣称自动通过平台审核。
+8. 某模块失败或 `quality_rejected` 时,不重新规划整页;用户确认直接补图时用原 `runId + retryOutputIds`,需要明确局部编辑时才把当前文件绝对路径传给 `fmode_image_set_edit`。宽幅/方形尺寸继承原模块,结果保存在原套图并更新原 run。

+ 31 - 21
claude-code/fmode-image-set/skills/fmode-image-set/SKILL.md

@@ -5,16 +5,16 @@ description: Plan and generate consistent e-commerce product image sets with Gem
 
 # Fmode Image Set
 
-把当前商品证据转成可确认的套图计划,并在用户明确确认人民币预算后调用 New API 执行。
+把当前商品证据转成可确认的套图计划,并在用户明确确认后调用 New API 执行;执行中的单图异常由 Skill 有限重试并继续后续图片
 
 ## 必须执行
 
-1. 当前请求包含图片、附件或 `[Unsupported Image]` 时,先调用 `fmode_image_set_analyze`,不要先 Glob 搜图,也不要要求用户重复上传
-2. 生成前必须调用 `fmode_image_set_plan`。计划阶段不得调用生图接口。
-3. 展示计划的张数、逐图用途、模型、尺寸、主要约束和预计费用;默认 `high_quality` 使用 Gemini,只有用户选择低固定成本/2K 备选时才用 Seedream。
-4. 只有用户明确确认计划和人民币预算后,才调用 `fmode_image_set_generate`,并传入 `confirmed=true`、`budgetLimitCny`、`sessionId`、`promptId`
-5. 遇到 `submission_unknown` 时停止全部后续提交。保留原 `runId`,通过网关账单/日志人工核验并由用户重新授权,绝不能自动换模型或重提
-6. 恢复已有任务时优先查询原运行,不得通过新建运行绕过失败、费用或幂等状态
+1. 用户在对话中粘贴当前项目内的图片路径时,直接把这些路径传给 `fmode_image_set_analyze`;不要 Glob 搜图、猜测历史图片或要求改用 VSCode 上传。相对路径按当前项目根解析
+2. 新任务生成前必须调用 `fmode_image_set_plan`;已有 `runId` 的失败续跑禁止再次识图或规划。计划阶段不得调用生图接口。
+3. 展示计划的张数、逐图用途、模型、尺寸和主要约束,不向用户展示预计、累计或实际生成费用;默认 `high_quality` 使用 Gemini,只有用户明确选择 2K 备选时才用 Seedream。
+4. 只有用户明确确认计划后,才调用 `fmode_image_set_generate`,并传入 `confirmed=true`、`sessionId`、`promptId`;`budgetLimitCny` 是旧调用兼容字段,新流程无需展示或要求用户填写。本次显式商品图路径必须继续通过 `referenceImages` 传入
+5. 单图遇到超时、连接中断、临时网关异常、坏响应或成功响应无图片时,由执行器使用稳定幂等键最多自动尝试 3 次;仍失败则记录该 outputId 并继续后续图片,不自动换模型
+6. 任务结束后一次性列出失败/拒绝/待恢复 outputId 并询问是否补图。用户确认后必须用原 `runId + retryOutputIds + confirmed=true` 只重试这些项;不得新建计划、重新生成已完成项或为绕过状态更换 run
 7. 工作区安装器通过受信环境变量 `FMODE_WORKSPACE_ROOT` 固定当前工作区;不要让 Tool 参数改写工作区、Claude 配置目录或输出根路径。
 
 ## 输入与证据
@@ -27,20 +27,25 @@ description: Plan and generate consistent e-commerce product image sets with Gem
 - 把 `visual_inferences` 明确保留为推断,不升级为商品事实。
 - 不从图片臆造功效、认证、成分、尺寸、价格、性能或法律声明。
 - 多图必须明确 identity、style、detail、model 等角色;无法区分时先询问一个最关键问题。
+- 用户明确说明多张路径都是同一商品的商品图时,将这些图片全部标记为 `identity`,不再要求逐张确认;只有混入风格、模特、细节或竞品参考且无法判断时才询问。
 - 最新请求无图时不得复用旧请求附件。
 
 ## 工作流
 
 1. 读取用户目标、平台、商品事实、参考图、品牌约束和输出范围。
-2. 有图时调用 `fmode_image_set_analyze`;让用户审核低置信度事实和未知项。
+2. 有显式片路径时调用 `fmode_image_set_analyze`;让用户审核低置信度事实和未知项,并保留原始路径供确认后的生成调用
 3. 信息足够时调用 `fmode_image_set_plan`:
+   - 用户明确声明商品名时传 `productName`;否则使用识图结果中的明确商品名,不能把视觉推断伪装成用户声明;
    - 普通套图默认 `compact` 七图;
    - 明确完整套图使用 `full`,保持 H1-H5/M1-M9 顺序;
    - 主图、副图、A+、多角度、广告、社媒请求使用对应 preset。
+   - 淘宝/天猫、京东、拼多多商品图片请求必须显式传 `platform`;逐项展示 `platformRule` 的资产用途、核验状态和警告。安全降级只能称为保守建议,不能称为官方合规。
 4. 向用户输出计划摘要,不把内部完整提示词全部铺开;用户要求时再展示逐项详情。
-5. 收到明确确认后调用 `fmode_image_set_generate`。
-6. 返回已完成文件绝对路径、部分完成项、费用状态和下一步。
-7. 修改已有图用 `fmode_image_set_edit`;高质量 2K 增强用 `fmode_image_set_upscale`,两者同样要求确认和预算。
+5. 收到明确确认后调用 `fmode_image_set_generate`;执行器自行处理单图临时异常并继续整个计划,不要求用户持续盯守。
+6. 返回中文运行目录、已完成文件绝对路径、失败 outputId 和下一步,不输出生成费用。完整成功时逐项告知商品原图的整理结果:项目根直属图片已移动到 `商品原图/`,项目子目录图片已复制到 `商品原图/`。
+7. 用户确认补图时,直接调用 `fmode_image_set_generate` 并传原 `runId`、明确的 `retryOutputIds`、新的当前请求 `sessionId/promptId` 和 `confirmed=true`;不要再次调用 analyze/plan。修改普通已有图用 `fmode_image_set_edit`;高质量 2K 增强用 `fmode_image_set_upscale`,两者同样要求明确确认。
+   - 质量拒绝项属于当前套图时,必须把该 item 当前 `files` 中的绝对路径原样传给 edit。Tool 会核对父目录 `state.json` 与 outputId,把结果以 `<outputId>-v<版本>-1.<扩展名>` 放回原套图并更新原 item/run;不得手工猜测 run。
+   - 历史文件不覆盖。相同返修请求幂等;返修 `submission_unknown` 时禁止换 promptId 自动重提。普通独立图片和已通过套图图片继续使用独立编辑运行。
 
 详细业务流程见 [references/workflow.md](references/workflow.md)。交付字段见 [references/output-format.md](references/output-format.md)。
 
@@ -50,24 +55,29 @@ description: Plan and generate consistent e-commerce product image sets with Gem
 - 每张图重复商品形状、包装、Logo、主色、材质和识别细节约束。
 - 跨图锁定品牌色、光向、镜头语言、阴影、色调和排版系统。
 - 平台适配必须重新构图,不能只缩放。
+- 国内平台规则只在明确命中淘宝/天猫、京东、拼多多且使用商品套图 preset 时生效;其他平台保持原有目录和行为。
+- 国内生成结果读取真实 PNG/JPEG/WebP 像素和比例;违反官方已核验的必需比例时标记 `quality_rejected` 并保留文件,建议项不满足时只告警。
 - 禁止伪造平台 UI、账号、互动数据、认证、水印和商品声明。
 - 交付前检查商品一致性、文字、Logo、颜色、尺寸、安全区和明显生成缺陷。
 - 固定调用 Gemini 做生成后复核;`quality_rejected` 保留文件但不能作为后续锚点,`unverified` 必须明确告知用户。
+- 套图返修通过后可将原拒绝 item 恢复为 `completed`;只有全部 item 完成时才执行既有商品原图整理。
 
-## 费用与外部动作
+## 外部动作与恢复
 
-- 识图使用 Gemini 配额;套图计划只使用本地规则,不调用生图接口。
-- 生图、编辑和超清是付费外部动作,必须显式确认
-- 付费提交顺序执行,单计划不超过 14 张;`submission_unknown` 后停止全部剩余提交
-- 预算单位是人民币;执行器按受信美元价格和 USD/CNY 汇率重新计算,不采信计划中的费用字段
-- 预算小于计划估算或增加张数时必须重新确认
+- 套图计划只使用本地规则,不调用生图接口;生图、编辑和超清属于外部写动作,必须显式确认
+- 单计划不超过 14 张。临时生成异常最多自动尝试 3 次;单项耗尽后继续后续项,任务结束再汇总失败 ID
+- 远程结果 URL 已取得时,下载失败只重试下载,不重新调用生图接口
+- 内部仍按受信价格计算安全上限,但该信息不得出现在普通用户计划、状态、总结或话术中;旧 `budgetLimitCny` 仅保留兼容
+- 同一自动重试周期保持稳定幂等键;网关是否执行该请求头仍待验证,因此尝试次数必须有界
 - 不在对话、日志、状态或文件中回显 API Key、Token 或 Authorization。
 
 ## 用户输出
 
-- 成功:计划/运行 ID、张数、状态、费用摘要、文件绝对路径、质量摘要。
+- 成功:计划/运行 ID、张数、状态、文件绝对路径、质量摘要;不输出预计、累计或实际费用。
+- 原图整理:逐项输出“已移动/已复制:原路径 → 新路径”;整理失败必须明确警告,禁止因此重新提交生成。
 - 部分完成:明确已完成、待处理、失败和 submission_unknown 项。
-- 阻断:说明缺少图片角色、事实、Token、确认、预算或余额中的哪一项。
+- 部分完成后的唯一补图路径:询问是否只重试列出的 outputId;确认后在原 run 续跑。
+- 阻断:说明缺少图片角色、事实、Token 或确认中的哪一项,不展示费用字段。
 - 默认不展示:完整上游响应、认证信息、Claude 会话日志和内部堆栈。
 
 ## 典型用户话术
@@ -75,6 +85,6 @@ description: Plan and generate consistent e-commerce product image sets with Gem
 ```text
 分析这张商品图,先规划七张套图,不要生图。
 给这款产品做完整十四图,保持包装和颜色一致。
-按刚才确认的高质量模型计划生成,人民币预算不超过 5.3 元
-继续查询刚才未完成的套图任务,不要重新提交
+按刚才确认的高质量模型计划生成。
+继续原任务,只重试 D4,不要重新规划或生成已完成图片
 ```

+ 20 - 2
claude-code/fmode-image-set/skills/fmode-image-set/references/output-format.md

@@ -7,13 +7,12 @@
 预设:<preset>
 数量:<n>
 生图模型:<providerModel>
-预计预留:¥<amount>
 需要确认:是
 
 1. <outputId>|<title>|<width>x<height>|<purpose>
 ...
 
-待确认:张数、平台、模型、文案、人民币预算
+待确认:张数、平台、模型和文案
 ```
 
 ## 执行
@@ -23,8 +22,27 @@
 状态:completed / partial / submission_unknown
 完成:<completed>/<total>
 文件:<absolute paths>
+商品原图:已移动/已复制 <original path> → <new path>
 未完成项:<ids and reasons>
 下一步:<resume/review/edit/upscale>
 ```
 
+若存在失败项:
+
+```text
+已完成:<completed outputIds>
+失败/待恢复:<failed outputIds and reasons>
+是否只重试:<failed outputIds>?
+```
+
+确认后使用原 `runId + retryOutputIds`,不得重新规划或生成已完成项。
+
 不得输出真实 Token、Authorization、完整上游错误或用户会话日志。
+
+套图返修成功时额外说明:
+
+```text
+返修:<outputId> → <原套图目录>/<outputId>-v<版本>-1.<扩展名>
+历史文件:已保留
+原运行状态:completed / partial / quality_rejected / submission_unknown
+```

+ 17 - 4
claude-code/fmode-image-set/skills/fmode-image-set/references/workflow.md

@@ -11,7 +11,9 @@
 
 ## 1. 输入门禁
 
-最少需要商品描述或一张商品图。多图未标角色时不要假设,先确认 identity/style/detail/model。平台不明确时默认通用电商;付费广告和强平台规范场景必须明确平台。
+最少需要商品描述或一张商品图。用户可直接在对话中粘贴当前项目内图片路径;禁止通过 Glob 或历史文件猜图。用户明确多张图片均为同一商品时全部作为 identity,混合参考类型且无法判断时再确认 identity/style/detail/model。平台不明确时默认通用电商;付费广告和强平台规范场景必须明确平台。
+
+淘宝/天猫、京东、拼多多商品图片必须显式传平台。国内平台规则按资产类型附加到计划项;官方证据不足的数值保留为安全建议并输出警告,不得描述成官方合规结论。
 
 ## 2. 识图
 
@@ -36,17 +38,28 @@
 
 ## 4. 计划确认
 
-确认项:张数、平台、模型、尺寸、用途、商品事实、品牌约束、必要文字、预计人民币费用和 `budgetLimitCny`。任何关键项改变都重新生成计划并产生新的 `planHash`
+确认项:张数、平台、模型、尺寸、用途、商品事实、品牌约束和必要文字。普通用户不查看或填写生成费用。只有新计划的关键设计项改变才重新生成计划并产生新的 `planHash`;原 run 失败项续跑不是新计划
 
 ## 5. 生成与恢复
 
 - 使用同一 `sessionId + promptId + planHash` 保持幂等。
-- 执行器按受信美元价格与 USD/CNY 汇率重新核算人民币预算,付费提交顺序执行
+- 执行器内部保留可信安全上限,但不把预计、累计或实际费用放入用户输出
 - 第一张成功且未被质量拒绝的结果作为后续项目身份锚点。
 - 图片通过同步响应中的 Data URL/Base64 或 URL 接收并安全落盘,不要求公网参考图 URL。
-- `submission_unknown` 立即停止全部剩余提交;本地 status 无法替上游判定是否已受理,禁止自动换模型或重提。
+- 超时、连接中断、408/429/5xx、坏 JSON 或成功响应无图片时,同一 outputId 使用稳定幂等键最多自动尝试 3 次;耗尽后记录失败并继续后续 outputId,不自动换模型。
+- 已获得远程结果 URL 后,下载异常只重试下载;耗尽时持久化 URL,后续通过原 run 的 status refresh 恢复,不重新生成。
+- 整套执行结束后列出失败/拒绝/跳过项。用户确认补图后以原 `runId + retryOutputIds` 只重试指定项,保持已完成项的 requestId 和文件不变;严禁重新识图、规划或创建另一套运行。
 - 状态 Tool 只读取原 `runId` 的本地脱敏记录。
+- 新运行写入 `输出/商品套图/<商品名称>-<短请求标识>/`。全部计划项完成后,项目根直属商品图移动到 `商品原图/`,项目子目录商品图复制到该目录;其他运行状态不移动根目录原图。
 
 ## 6. 质量复核
 
 固定用 `gemini-3.1-flash-image-preview` 逐图检查身份、颜色、Logo/文字、平台、安全区、构图、光照和生成缺陷;跨图检查身份锁、风格锁与信息叙事顺序。复核不可用时必须标记 `unverified`;复核失败时标记 `quality_rejected` 并保留文件供人工检查。
+
+国内平台额外读取生成文件的真实 PNG/JPEG/WebP 格式、像素、比例与透明通道。官方已核验的必需比例不满足时拒绝该项;建议尺寸或透明通道不满足时告警并保留人工处理入口。
+
+## 7. 套图单图返修
+
+生成结果中的 item 为 `quality_rejected` 时,可按用户意图选择两种原 run 路径:直接补图时用 `runId + retryOutputIds`;需要明确局部修改提示词时,使用该 item 当前 `files` 返回的绝对路径调用 `fmode_image_set_edit`。原位编辑会核对路径、父 run 状态和 outputId,新文件保存在原套图目录且历史文件不覆盖。普通用户输出不展示内部费用记录,其他图片编辑保持独立运行。
+
+返修提交不确定时保留 `submission_unknown`;远程结果 URL 已持久化时只能通过原 run 的 status refresh 恢复下载。

+ 1 - 1
claude-code/fmode-image-set/skills/fmode-multi-angle/SKILL.md

@@ -10,4 +10,4 @@ description: Generate a nine-view identity-locked fashion, footwear, bag, access
 3. 调用 `fmode_image_set_plan`,设置 `preset=multi-angle`。
 4. 九个角度必须共享相同光线、镜头、色调和背景;只改变角度/姿态。
 5. 计划中任何身份事实不确定时先让用户确认。
-6. 展示九图计划与费用,确认后调用 `fmode_image_set_generate`;失败只恢复对应角度。
+6. 展示九图计划、角度和尺寸,不展示生成费用;确认后调用 `fmode_image_set_generate`。单个角度失败不阻断后续角度,结束后只用原 `runId + retryOutputIds` 恢复对应角度。

+ 9 - 6
claude-code/fmode-image-set/skills/fmode-product-main-image/SKILL.md

@@ -1,12 +1,15 @@
 ---
 name: fmode-product-main-image
-description: Plan and generate compliant e-commerce main images and secondary listing images with product identity consistency. Use for 亚马逊主图、副图、白底商品图、listing images、Amazon main image、产品轮播图、主图套装.
+description: Plan and generate evidence-aware e-commerce main images and secondary listing images with product identity consistency. Use for 淘宝主图、天猫主图、京东主图、拼多多主图、亚马逊主图、副图、白底商品图、listing images、Amazon main image、产品轮播图、主图套装.
 ---
 
 # Fmode Product Main Image
 
-1. 有参考图时先调用 `fmode_image_set_analyze`。
-2. 调用 `fmode_image_set_plan`,设置 `preset=main-image`,平台明确时传入平台。
-3. 主图锁定纯白/平台背景、主体比例、无多余道具、无水印和无虚构文字;副图按卖点、多角度、细节、场景、组合、包装和尺寸分工。
-4. 先展示八图计划和预计费用,用户确认后才调用 `fmode_image_set_generate`。
-5. 以确认主图作为后续副图身份锚点;平台变化必须重新构图而非仅缩放。
+1. 用户粘贴项目内参考图路径时直接传给 `fmode_image_set_analyze`,不要 Glob 或猜测历史图片;同一商品的多张图可全部使用 identity 角色。
+2. 调用 `fmode_image_set_plan`,设置 `preset=main-image`,平台明确时传入平台;淘宝/天猫、京东、拼多多不得省略 `platform`。
+3. 国内平台逐图读取 `platformRule`:区分主图、白底图、卖点、副图、场景、包装和尺寸资产;展示证据状态。`safe_fallback` 只能称为保守建议,禁止宣称平台官方合规。
+4. 主图锁定纯白/平台背景、主体比例、无多余道具、无水印和无虚构文字;副图按卖点、多角度、细节、场景、组合、包装和尺寸分工。
+5. 先展示八图计划、用途和尺寸,不展示生成费用;用户确认后调用 `fmode_image_set_generate`,并将原始路径继续作为 `referenceImages` 传入。
+6. 完整成功后报告中文运行目录、国内平台真实像素/比例检查结果,并逐项告知项目根原图的移动位置和项目子目录原图的复制位置。
+7. 以确认主图作为后续副图身份锚点;平台变化必须重新构图而非仅缩放。
+8. 某项失败或 `quality_rejected` 时,不得重新规划八图。用户确认直接补图时用原 `runId + retryOutputIds`;需要明确局部编辑时才把当前文件绝对路径传给 `fmode_image_set_edit`。结果使用版本名留在原套图并更新原 run,不得进入新的顶层“单图处理”目录。

+ 3 - 3
claude-code/fmode-image-set/skills/fmode-product-vision/SKILL.md

@@ -5,9 +5,9 @@ description: Analyze product images with gemini-3.1-flash-image-preview for visi
 
 # Fmode Product Vision
 
-1. 先调用 `fmode_image_set_analyze`,不要先搜索文件,也不要要求重新上传当前附件
-2. 多图先确认 identity/style/detail/model 角色;最新请求无图时不得复用旧图。
+1. 用户粘贴当前项目内图片路径时,直接把路径传给 `fmode_image_set_analyze`;不要 Glob 搜索、猜测历史文件或要求改用 VSCode 上传
+2. 用户声明多张图片均为同一商品图时全部使用 identity 角色;混合 style/detail/model 且无法判断时才确认角色。最新请求无图时不得复用旧图。
 3. 把结果分成可见事实、视觉推断和未知项。
 4. 不把材质猜测升级为成分,不臆造功效、认证、尺寸、价格和性能。
 5. 输出商品身份锚点、品牌色、可见文字、风险与需要用户确认的字段。
-6. 用户要继续做套图时,把结构化 `analysis` 传给 `fmode_image_set_plan`。
+6. 用户要继续做套图时,把结构化 `analysis` 和用户明确声明的 `productName` 传给 `fmode_image_set_plan`,并保留原始图片路径供生成时作为 `referenceImages` 传回

+ 1 - 1
claude-code/fmode-image-set/skills/fmode-social-visual/SKILL.md

@@ -10,4 +10,4 @@ description: Plan and generate organic social feed, story, reel-cover, and carou
 3. 先锁尺寸和安全区,再决定构图;轮播第一页负责三秒钩子,末页负责行动收束。
 4. 跨页重复身份锁、品牌色、字体策略、光线和版式网格。
 5. 不生成平台 UI、账号、点赞数、评论框、状态栏或工具水印;长 caption 单独输出,不渲染进图片。
-6. 展示页数、尺寸和预算,确认后调用 `fmode_image_set_generate`
+6. 展示页数、尺寸和用途,不展示生成费用;确认后调用 `fmode_image_set_generate`。失败页只按原 `runId + retryOutputIds` 补图,不重做已完成页面

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است