topfans/docs/superpowers/specs/2026-07-07-laser-card-dify-workflow-refactor-design.md
2026-07-09 16:08:25 +08:00

1552 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 镭射卡 Dify 工作流重构设计v2 — 5 系列 × 主题叙事)
> **作者**Claudebrainstorming with user
> **创建日期**2026-07-07
> **关联 commit**139d91dfeat/len 分支)
> **状态**:草案,待用户审 spec
---
## 1. 方案概述(必读)
### 1.1 要解决的问题
**业务问题**
- 当前用户进入镭射卡生成后看到的是"AI 出图但没有金属镭射光效"的 5 张普通图
- 5 张预设dream/classic/holoFull/ice/sunset都是"金属银 + 单一彩虹带",物理上同质,用户感觉"5 张几乎一样"
- 用户倾斜手机时,前端无任何视觉反馈(当前缺前端 WebGL tilt 渲染层)
**技术问题**
- 前端 `useLaserBatchGenerate.js` 硬编码 `genMode = 'openai'`,但代码里同时存在 `minimax / dify / openai / relay-dify` 4 个 provider 分支minimax 是默认 provider 但前端从不触发)
- OpenAI `/v1/images/edits` 路径**跳过了 compositor 服务**,导致 GPT-image 不擅长金属镭射 → 出图无光效
- 45 风格池(`frontend/utils/laser-card/stylePool.js` + `frontend/utils/laser-card/gacha.js`)整套 dead code无业务方调用**已删除**
- 后端 compositor 服务(`backend/gateway/service/compositor/`)有完整的 6 层合成能力,但当前链路不用
### 1.2 整体实现路径
| 阶段 | 时长 | 内容 |
|---|---|---|
| Phase 1: 锁定 provider | 0.5d | 前端 `useLaserBatchGenerate``genMode = 'dify'`;后端默认 `LASER_GEN_PROVIDER=dify` |
| Phase 2: 搭建 Dify 工作流 | 1d | 按本文档第 5 节配置 `laser_card_v2_5series` 工作流 |
| Phase 3: 后端适配 | 0.5d | `handleDifyBlocking` 已存在,只需适配新 variant 字段 |
| Phase 4: 测试 + 灰度 | 1d | 端到端测试 → 灰度切流 → 全量 |
**总工作量**:约 3 人日(含 Dify 配置调试)
### 1.3 关键决策
| 决策 | 选择 | 理由 | 详细 |
|---|---|---|---|
| 生成链路 | Dify 工作流统一编排 | 用户选择路径 C编排逻辑可视化便于调优 | §3 |
| 5 张差异化 | 5 个卡牌系列 × 主题叙事 | 工艺 + 主题双重差异;用户视觉一眼可辨 | §4 |
| AI 模型(文本) | gpt-4o-mini推荐或 minimax-m3备选 | gpt-4o-mini 便宜稳定minimax-m3 国内可用但对文化符号理解弱 | §5.3 |
| AI 模型(图像) | gpt-image-1推荐或 Dify 平台支持的图像模型 | gpt-image-1 质量高;如不支持换 SDXL/Replicate Flux/通义万相 | §5.4 |
| AI 角色 | AI 出"金属底卡"(金属色 + 风格场景,不含彩虹扫光) | 彩虹由前端 WebGL 实时叠,保证"不同角度不同色彩"可控、稳定 | §3 + §12 |
| 前端叠层 | WebGL tilt 实时渲染(基于 LenticularEngine | 复用现有引擎;用户倾斜手机时色彩随 viewAngle 偏移 | §12 |
| 后端 compositor | **必须保留并增强** | AI 出金属底 + 后端 compositor 合成"人像 + 镭射光效",最终成品图 | §6.3 + §13 |
| 数据库 | render_config JSONB 沿用 `backdrop_tone` 字段(不做 schema 变更) | 前端读 `backdrop_tone` 决定 metal 颜色;新增数据通过现有字段表达 | §7 |
### 1.4 核心架构图TL;DR
```
用户上传原图 + 提示词
[前端] useLaserDifyGenerate.submit()
↓ POST /api/v1/laser/generate
↓ body: {cutout_url, preset_codes:[v1..v5], render_configs:[5]}
↓ (用户风格描述词由 Dify 工作流 #context# 注入,无需前端传)
[后端] handleDifyBlocking()
↓ 调 Dify /v1/workflows/run
[Dify] 工作流 laser_card_v2_5series:
Step 1: LLM 节点 (gpt-4o-mini) — 扩写 5 个系列化子 prompt
Step 2: Code 节点 — 拆分 JSON 为 5 变量
Step 3: 5 路并发生图 (gpt-image-1) — 每路 1 张"金属底 + 主题"图
Step 4: 结束节点 — 返回 variants 数组
[后端] 解析 variants + persistGeneratedInstance
↓ 写 laser_card_instances.materials_snapshot
[前端] 5 张图展示 + 用户选 1
┌────────────────────────────┐
│ LenticularCard.vue │
│ ↓ │
│ useLenticularCraftTiltPreview │ ← 陀螺仪 / 触摸 输入
│ ↓ │
│ LenticularEngine (WebGL) │ ← 计算每帧 rainbow offset
│ ↓ │
│ holographic-shaders │ ← shader 输出"色带随 viewAngle 漂"
└────────────────────────────┘
↓ 倾斜手机 → 看到不同色彩
[铸造] craftMintSubmit → 标准铸造流程
```
---
## 2. 当前状态分析
### 2.1 现有代码资产(保留)
| 文件 | 用途 | 处置 |
|---|---|---|
| `backend/gateway/controller/laser_generate_controller.go` | POST /api/v1/laser/generate | **保留**,仅调整 `handleDifyBlocking` 解析逻辑 |
| `backend/gateway/repository/laser_card_repository.go` | DB CRUD | **保留** |
| `backend/pkg/models/laser_card.go` | 模型定义 | **保留** |
| `frontend/composables/useLaserDifyGenerate.js` | 前端主调用 | **保留**,调整 render_configs 数量 |
| `frontend/utils/laser-card/laserPresets.js` | 5 套 preset 定义 | **保留**,但改名/调整 |
| `frontend/utils/laser-card/laserGrating.js` | grating 参数 | **保留**grating_config 字段兼容) |
### 2.2 现有代码资产(废弃)
| 文件 | 处置 | commit |
|---|---|---|
| 4 个 provider 中的 minimax/openai/relay-dify 分支 | 降级为注释,保留 dify | 本次 PR |
| `backend/gateway/service/compositor/*.go` | 标记 DEPRECATED | 后续 PR |
| `backend/gateway/controller/compositor_controller.go` | 标记 DEPRECATED | 后续 PR |
| `backend/gateway/service/openai_client.go` | 保留(兼容字段),但激光卡不再调 | 本次 PR |
---
## 3. Dify 工作流设计
### 3.1 工作流基本信息
| 字段 | 值 |
|---|---|
| 名称 | `laser_card_v2_5series` |
| 类型 | WorkflowAPI 触发) |
| 触发方式 | HTTP POST /v1/workflows/run |
| 预计耗时 | 30-60 秒5 路 AI 生图并发) |
| 输出 | JSON 数组 variants[5] + warnings + cutout_url |
### 3.2 工作流节点全景图
完整可视化流程图见:[`dify-laser-workflow.drawio`](../../../dify-laser-workflow.drawio)
```
[开始] → [LLM: 扩写 prompt] → [Code: 拆分 JSON]
并发 5 路(每路 1 个系列)
┌────────┬────────┬────────┬────────┐
↓ ↓ ↓ ↓ ↓
[生图1] [生图2] [生图3] [生图4] [生图5]
Prizm Pokemon Topps YuGiOh Weiss
Blue Rainbow Refr. Secret SSP
↓ ↓ ↓ ↓ ↓
└────────┴────────┴────────┴────────┘
[结束: 输出 variants]
```
### 3.3 节点清单10 个)
| # | 类型 | 名称 | 输入 | 输出 |
|---|---|---|---|---|
| 1 | 开始 | `start` | — | cutout_url用户主题由 Dify 原生 `#context#` 注入) |
| 2 | LLM | `llm_rewrite` | `#context#`user message | JSON: 5 个系列 prompt |
| 3 | — | **跳过**Dify 自动拆 JSON见 §5.3 | — | — |
| 4 | 生图 | `gen_prizm_blue` | `{{ llm_rewrite.prizm_blue}}` + cutout_url | img_1 (signed_url) |
| 5 | 生图 | `gen_pokemon_rainbow` | `{{ llm_rewrite.pokemon_rainbow}}` + cutout_url | img_2 |
| 6 | 生图 | `gen_topps_refractor` | `{{ llm_rewrite.topps_refractor}}` + cutout_url | img_3 |
| 7 | 生图 | `gen_yugioh_secret` | `{{ llm_rewrite.yugioh_secret}}` + cutout_url | img_4 |
| 8 | 生图 | `gen_ws_ssp` | `{{ llm_rewrite.ws_ssp}}` + cutout_url | img_5 |
| 9 | 代码 | `code_merge` | 5 个 img URL | variants JSON 数组 |
| 10 | 结束 | `end` | variants, warnings, cutout_url | — |
---
## 4. 5 个卡牌系列定义
### 4.1 系列与 preset_id 对应
| preset_id | 中文名 | family | 工艺特征 | metal tone | sheen angle | foil |
|---|---|---|---|---|---|---|
| `prizm_blue` | 蓝棱镜 | panini_prizm | 电光蓝宝石 + 锐利反射 | 冷银 #A8ACB2 | 120° | 0.70 |
| `pokemon_rainbow` | 宝可梦彩虹 | pokemon_tcg | 全光谱彩虹 + 雪花点 | 珍珠银 #B0ACA6 | 100° | 0.90 |
| `topps_refractor` | Topps 经典 | topps_chrome | 单彩虹带 + 复古胶片 | 冷银 #A8ACB2 | 125° | 0.68 |
| `yugioh_secret` | 游戏王 Secret | yugioh | 全卡横纹扫光 | 珍珠银 #B0ACA6 | 90° | 0.90 |
| `ws_ssp` | WS SSP | weiss | 日系终极收藏 + 全卡 rainbow | 冷银 #A8ACB2 | 95° | 0.95 |
### 4.2 Dify 数据库初始化
Dify 工作流的 `laser_card_templates` 表需要同步 5 条记录(与前端 preset_id 对齐。SQL 见 §7。
---
## 5. Dify 工作流节点配置(小白填写指南)
> **重要**:以下每个节点都标注了"在 Dify 平台怎么找这个节点"。如果你找不到对应类型,问 Dify 平台客服或看他们的官方文档。
### 5.1 节点 1开始节点
**在 Dify 平台**:新建工作流时默认就有;如果已有工作流,在画布左上角点"+",选"开始"。
**填写步骤**
1. 节点标题:`开始`(默认)
2. 在"输入变量"区域,点"+ 添加变量"**只添加 1 个自定义变量**(用户主题用 Dify 原生 `#context#` 注入,不要添加自定义变量):
| 变量名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `cutout_url` | 文本 (string) | ✓ | — | 用户原图的 OSS signed URL |
3. **用户风格描述词不要做变量**Dify 工作流运行时,平台会自动把"用户当前输入消息"注入为 `#context#` 特殊变量。在 LLM 节点 user prompt 字段直接填 `#context#` 即可(无需自定义变量)。
4. 点"保存"按钮
**常见错误**
- 添加了 `user_prompt` 自定义变量 → 多余且容易混淆LLM 会拿到重复信息)
- 必填勾错:所有变量都勾必填会阻止测试运行
---
### 5.2 节点 2LLM 节点(关键节点)
**在 Dify 平台**:点画布"+",搜索"LLM",选第一个(带大模型图标的)。
**填写步骤**
1. **节点标题**`llm_rewrite`(任意,方便引用)
2. **模型**:下拉选 `gpt-4o-mini`(便宜、快速;如果你想质量更高可改 `gpt-4o`
3. **系统提示 (System Prompt)**:粘贴下面整段 ↓
```
你是镭射卡视觉设计师 + 卡牌工艺专家。
【输入说明】用户输入的"风格描述词"会通过 Dify 的 #context# 特殊变量传入到下方 user message 字段。
用户的输入是"镭射卡想要的氛围/风格",例如"梦幻樱花"、"星空极光"、"赛博朋克"、"蒸汽朋克"。
你的任务是:读 user message 中的风格词,把它嵌入到下方 5 个系列的模板中(替换 `{主题}` 占位符)。
每个 prompt 对应一种真实存在的卡牌工艺系列5 张图都是同一风格、不同工艺底,让用户对比"哪个光效最适合"。
【关键】每张图只需要出"金属底 + 风格氛围",不要出彩虹扫光带!
彩虹扫光带由前端 WebGL 根据用户倾斜角度实时叠加,与 AI 出的图无关。
AI 出图只需要保证:金属底色 + 风格氛围(场景/色彩/质感),让前端去做"光感"。
【模板占位符约定】下方 5 个系列模板中的 `{主题}` 是占位符,**你在输出时必须把 `{主题}` 替换为 user message 中的实际风格词**,不要保留 `{主题}` 字面量。
## 【系统 prompt 内部小节】5 个卡牌系列(每张的金属底 + 工艺特征严格对齐下方)
> **针对 MiniMax image-01 优化版**:由于 MiniMax 不支持 Negative Prompt、且对"金属 + 镭射"理解较弱,模板增加了**视觉强约束词**和**摄影术语**以提升出图质量。
### Series 1: Prizm Blue (美式运动卡蓝棱镜)
- 金属底:冷银 #A8ACB2偏蓝宝石色
- 主题特征:锐利高对比、运动卡质感、人像居中、运动场景元素
- bg_prompt 模板:"{主题}, on cool silver #A8ACB2 brushed metal card background, electric blue sapphire accent, sharp reflective sport card aesthetic, centered subject, 50mm lens, photorealistic, 4k, detailed, no text, no people, no watermark"
### Series 2: Pokemon Rainbow Rare (宝可梦全谱彩虹)
- 金属底:珍珠银 #B0ACA6柔和高亮
- 主题特征:日系动漫风、可爱人物、闪闪发光背景、精致细节
- bg_prompt 模板:"{主题}, on pearl silver #B0ACA6 lustrous metal card background, soft luminous anime glow, premium Japanese TCG aesthetic, centered chibi-style subject, vibrant pastel sparkles, 4k illustration, detailed, no text, no people, no watermark"
### Series 3: Topps Refractor (棒球经典折射)
- 金属底:冷银 #A8ACB2复古胶片颗粒
- 主题特征:复古运动卡、肖像构图、专业感、暗角
- bg_prompt 模板:"{主题}, on cool silver #A8ACB2 vintage chrome card background, vintage baseball card aesthetic, soft film grain texture, classic portrait composition, vignette edges, analog warmth, professional studio lighting, 4k, photorealistic, no text, no people, no watermark"
### Series 4: Yu-Gi-Oh Secret (游戏王 Secret 横纹)
- 金属底:珍珠银 #B0ACA6全卡面爆闪
- 主题特征:日系动漫、神秘角色、魔法阵元素、动态构图
- bg_prompt 模板:"{主题}, on pearl silver #B0ACA6 full-card luminous metal background, dynamic horizontal stripe holographic shine, magic circle sigils beneath, dynamic action pose, anime TCG secret-tier aesthetic, 4k illustration, detailed, no text, no people, no watermark"
### Series 5: Weiss SSP (WS 终极签名)
- 金属底:冷银 #A8ACB2极致金属光泽
- 主题特征:日系动漫终极收藏、签名感、豪华边框感
- bg_prompt 模板:"{主题}, on cool silver #A8ACB2 premium polished metal card background, signature-style framed composition, anime collectible apex aesthetic, luxurious engraved border details, ultra-polished mirror surface, 4k illustration, detailed, no text, no people, no watermark"
### 优化点说明MiniMax 适配)
| 改动 | 原因 |
|---|---|
| 加 `brushed metal / lustrous / vintage chrome / polished` | 强调物理金属质感MiniMax 对此敏感) |
| 加 `50mm lens / photorealistic / 4k / detailed` | 摄影/质量术语提升写实度 |
| 加 `centered subject` | 强制主体居中(避免 AI 随意构图) |
| 加 `vignette edges / analog warmth`Topps | 复古感细节 |
| 加 `vibrant pastel sparkles`Pokemon | 闪光粒子细节 |
| 加 `dynamic horizontal stripe`YGO | 横纹扫光AI 能直接渲,不用前端叠) |
| 加 `engraved border details`WS | 雕刻边框细节 |
**前提**:因为 MiniMax 不支持 Negative Prompt所以"no text, no people, no watermark"必须塞进**每个 prompt 末尾**作为正向约束LLM 强制约束已要求)。
## 【prompt 内部】输出格式(严格 JSON
{
"prizm_blue": "<英文 prompt>",
"pokemon_rainbow": "<英文 prompt>",
"topps_refractor": "<英文 prompt>",
"yugioh_secret": "<英文 prompt>",
"ws_ssp": "<英文 prompt>"
}
## 【prompt 内部】强制约束
1. 用户输入的"风格描述词"必须出现在 5 个 prompt 里(替换 `{主题}` 占位符)
2. 中文风格词翻译成地道的英文后再嵌入(如"梦幻樱花" → "dreamy cherry blossom""星空极光" → "starry aurora""赛博朋克" → "cyberpunk neon city"
3. 每个 prompt 末尾必须包含 "no text, no people, no watermark"
4. 金属底色必须严格对齐上方 hex 数值(#A8ACB2 / #B0ACA6
5. 5 个 prompt 之间必须有明显的视觉差异(不能换汤不换药)
6. 【严禁】出现 "holographic / rainbow / sheen / foil / refractive / prismatic / iridescent" 等词
(前端 WebGL 会处理这些AI 不要画蛇添足)
7. 禁止 "flat / matte / dull / cartoon / drawing / 2D / sketch" 等削弱金属感的词
8. 必须强调金属感:使用 "metallic / lustrous / polished / brushed metal / chrome / silver substrate" 等词
```
4. **用户提示 (User Prompt)**:填 `#context#`**Dify 原生特殊变量**,不是 `{{...}}` 形式)
- 怎么填:直接在用户提示框打 `#context#`Dify 平台会识别这个特殊变量,自动注入用户当前消息)
- 也可写:`用户的主题是:#context#`(更明确)
5. **输出格式**:选 **JSON**(重要!不选 JSON 后续 Code 节点没法解析)
6. 点"保存"
**常见错误**
- 输出格式忘记选 JSON → 后面的 Code 节点解析失败
- 模板占位符没有让 LLM 替换(如保留 `{主题}` 字面量输出)→ 生图节点会拿到 `{主题}` 字符串而报错
- 模型选了太贵的(如 gpt-4-vision-preview→ 费用爆炸
- 没限定用户输入是"风格/氛围词" → 用户可能输入 IP 名(如 PokemonAI 不知道怎么处理
---
### 5.3 节点 3**跳过**(当前生产方案)
**当前生产方案**:不创建代码节点 3让 5 个生图节点直接引用 LLM 的 JSON 子字段。**完全避开 Dify sandbox**(当前线上 sandbox 0.2.15 有 `DifySeccomp abort` bugPython 代码节点无法运行)。
**连线**
```
[LLM llm_rewrite] (输出格式=JSON)
├─→ [生图 4] prompt = {{ llm_rewrite.prizm_blue}}
├─→ [生图 5] prompt = {{ llm_rewrite.pokemon_rainbow}}
├─→ [生图 6] prompt = {{ llm_rewrite.topps_refractor}}
├─→ [生图 7] prompt = {{ llm_rewrite.yugioh_secret}}
└─→ [生图 8] prompt = {{ llm_rewrite.ws_ssp}}
```
**Dify 平台操作步骤**
1. **配置 LLM 节点**:在 `llm_rewrite` 节点编辑界面:
- 系统提示字段:粘贴 spec §5.2 完整中文 prompt
- 用户提示字段:填 `#context#`
- 滚到下方"输出变量"区域,**打开"结构化输出"开关**
- 在 JSON Schema 输入框粘贴:
```json
{
"type": "object",
"properties": {
"prizm_blue": { "type": "string" },
"pokemon_rainbow": { "type": "string" },
"topps_refractor": { "type": "string" },
"yugioh_secret": { "type": "string" },
"ws_ssp": { "type": "string" }
},
"required": ["prizm_blue", "pokemon_rainbow", "topps_refractor", "yugioh_secret", "ws_ssp"]
}
```
2. **修改每个生图节点的 Prompt 字段**(用变量选择器选,不要手敲):
| 节点 | Prompt 字段 |
|---|---|
| `gen_prizm_blue` | 用 `/` 选 → `llm_rewrite.structured_output.prizm_blue` |
| `gen_pokemon_rainbow` | `llm_rewrite.structured_output.pokemon_rainbow` |
| `gen_topps_refractor` | `llm_rewrite.structured_output.topps_refractor` |
| `gen_yugioh_secret` | `llm_rewrite.structured_output.yugioh_secret` |
| `gen_ws_ssp` | `llm_rewrite.structured_output.ws_ssp` |
3. **不要创建任何代码节点**
4. 保存 + 测试
**为什么这样能跑通**
- ✓ 完全不调 Python sandbox → 避开 `DifySeccomp abort` bug
- ✓ Dify 1.x 结构化输出自动 parse → 用平台验证过的功能
- ✓ 不需要 import json / json.loads → 减少代码出错面
**前提条件**
- LLM 节点系统提示是 spec §5.2 完整中文 prompt
- LLM 节点打开"结构化输出"开关
- 用户提示字段填 `#context#`
**常见错误**
- LLM 节点没打开"结构化输出" → 下游拿不到字段,路径语法失败
- LLM 系统提示用了对话式英文模板(不是 spec §5.2 中文版)→ LLM 输出问句而不是 JSON
- 路径语法写错:`llm_rewrite.structured_output.prizm_blue` 是正确的,不是 `.json.`
- JSON Schema 字段名拼错:必须严格是 `prizm_blue` / `pokemon_rainbow`
### 5.3.1 备用方案:用代码节点(沙箱修复后启用)
**何时启用**:等 Dify sandbox 升级到 0.2.16+ 或 Dify 平台提供新 sandbox 镜像Python 代码节点能稳定运行后,再启用此方案作为冗余。
**当前状态**:仅作参考,**不要在生产中部署**(线上 sandbox 0.2.15 会 abort
**实现思路**
在 LLM 节点和生图节点之间加一个 `code_split` 代码节点:
```python
import json
def main(llm_output: str) -> dict:
cleaned = llm_output.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("```")[1]
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
return {
"p1": data.get("prizm_blue", ""),
"p2": data.get("pokemon_rainbow", ""),
"p3": data.get("topps_refractor", ""),
"p4": data.get("yugioh_secret", ""),
"p5": data.get("ws_ssp", ""),
}
```
输出 5 个变量 `p1` ~ `p5`,生图节点引用 `{{ code_split.p1}}` ... `{{ code_split.p5}}`
**操作**
1. 在 LLM 节点配置界面勾选**"输出格式 = JSON"**Dify 会自动 parse
2. 在生图节点 4 的 Prompt 字段打:`{{ llm_rewrite.prizm_blue}}`
3. 其他 4 个生图节点类似,分别引用不同 key
4. 删掉原来的 code_split 节点
5. 测试运行
**为什么能跑通**
- 完全不调 Python sandbox → 避开 seccomp bug
- 不需要 import json / json.loads → 减少代码出错面
- Dify 平台自身负责 JSON parse → 用平台经过验证的功能
**常见错误**
- 路径语法写错:`llm_rewrite.json.prizm_blue` 是正确的,不要写 `llm_rewrite.prizm_blue`
- LLM 节点没勾"输出格式=JSON"→ Dify 不会自动 parse路径语法失败
- LLM 偶尔返回 `null` 字段 → 生图节点拿到空字符串AI 出图失败;建议在 system prompt 加强约束
---
### 5.4 节点 4-85 个生图节点HTTP 请求节点方案)
**为什么用 HTTP 请求节点而不是 Dify 图像生成插件**
- Dify 的 `langgenius/minimax` 插件**只支持 llm/text-embedding/tts****不支持 image_generation**
- 即使插件能调 MiniMax LLM**也调不到 MiniMax image_generation 端点**(图生图 API
-**HTTP 请求节点** 直接 POST `https://api.minimaxi.com/v1/image_generation`,传 `subject_reference` 参数实现**图生图**
**在 Dify 平台**:点"+",搜索 **"HTTP 请求" / "HTTP Request"**,选第一个插件。
**通用填写步骤**(以节点 4 为例,节点 5-8 改 prompt 字段):
#### 节点 4HTTP 请求节点 1Prizm Blue
1. **节点标题**`gen_prizm_blue`
2. **方法**POST
3. **URL**`https://api.minimaxi.com/v1/image_generation`
4. **Headers**JSON
```json
{
"Authorization": "Bearer {{ env.MINIMAX_API_KEY}}",
"Content-Type": "application/json"
}
```
> ⚠️ 在 Dify 控制台"工具 → MiniMax 凭据"页面把 API Key 配置为环境变量 `MINIMAX_API_KEY`,不要硬编码到节点里。
5. **Body**JSON
```json
{
"model": "image-01-live",
"prompt": "{{ llm_rewrite.prizm_blue}}",
"subject_reference": [
{
"type": "character",
"image_file": "{{ start.cutout_url}}"
}
],
"aspect_ratio": "1:1",
"n": "1",
"response_format": "url"
}
```
> 关键参数:`subject_reference[].type = "character"` + `image_file = 抠图 URL`,这是 MiniMax 图生图 API 的标准用法。
6. **超时**60 秒
7. **错误处理**保持默认Stop on Error
8. 点"保存"
#### 节点 5-8复制节点 4 修改
每个节点的差异**只改 prompt 字段**
| 节点 | 标题 | Body prompt 字段 |
|---|---|---|
| 5 | `gen_pokemon_rainbow` | `{{ llm_rewrite.pokemon_rainbow}}` |
| 6 | `gen_topps_refractor` | `{{ llm_rewrite.topps_refractor}}` |
| 7 | `gen_yugioh_secret` | `{{ llm_rewrite.yugioh_secret}}` |
| 8 | `gen_ws_ssp` | `{{ llm_rewrite.ws_ssp}}` |
**Dify 平台操作**
- 节点 4 配置完 → 节点右键 → "复制"
- 改标题 + 改 prompt 字段(共复制 4 次)
#### 响应解析(用于下游汇总节点)
MiniMax API 响应格式:
```json
{
"data": {
"image_urls": ["https://...signed_url...", "..."]
},
"id": "03ff3cd0820949eb8a410056b5f21d38",
"base_resp": {"status_code": 0, "status_msg": "success"}
}
```
**取第 1 张图 URL 的引用**(用于下游汇总节点):
```
{{ gen_prizm_blue.data.image_urls[0] }}
```
**常见错误**
- API Key 写错 → 报 2049 / 1004 状态码(鉴权失败)
- `subject_reference` 类型写成 `"human"` 而非 `"character"` → 报 2013 状态码(参数错误)
- `prompt` 长度 > 1500 字符 → 报 2013 状态码
- `image_file` URL 失效OSS signed URL 过期)→ AI 看图失败
---
### 5.4.1 备选:使用其他支持图生图的模型
如果不用 MiniMax HTTP 请求节点方案,可以用:
| 模型 | 适配 Dify 插件 | 配置方式 |
|---|---|---|
| **gpt-image-1**OpenAI | 装 `langgenius/openai` 或官方 OpenAI 插件 | 节点用 Dify 图像生成插件直接选 `gpt-image-1`prompt 字段 + image 字段(传 cutout_url |
| **Replicate Flux** | 装 `langgenius/replicate` | 配置 Replicate API token模型选 `flux-dev`,支持图生图 |
| **SDXL + img2img** | 装 `langgenius/sdxl` 插件 | 选 img2img 模型,加 `init_image` 字段 |
---
### 5.4.2 不推荐:保留 MiniMax 纯文生图(无图生图)
如果不想改 Dify 工作流、不想装新插件,可以接受:
- AI 只出"金属底 + 风格氛围"图(纯背景,无人像)
- 用户上传的人像**不会被嵌入**
- 前端 LenticularCard 显示时**用人像覆盖背景图**(临时方案)
**这种方案用户体验差**,不建议生产使用。
---
### 5.5 节点 9代码节点汇总
**在 Dify 平台**:同 §5.3,再加一个 Python 节点。
**填写步骤**
1. **节点标题**`code_merge`
2. **输入变量**:依次添加 5 个生图节点的输出变量(每个节点的图片 URL
3. **代码**
```python
import json
def main(gen1: str, gen2: str, gen3: str, gen4: str, gen5: str) -> dict:
"""
输入: 5 个生图节点的图片 URL
输出: variants 数组(与后端契约对齐)
"""
variants = [
{"preset_id": "prizm_blue", "oss_key": "", "signed_url": gen1},
{"preset_id": "pokemon_rainbow", "oss_key": "", "signed_url": gen2},
{"preset_id": "topps_refractor", "oss_key": "", "signed_url": gen3},
{"preset_id": "yugioh_secret", "oss_key": "", "signed_url": gen4},
{"preset_id": "ws_ssp", "oss_key": "", "signed_url": gen5},
]
return {
"variants": json.dumps(variants, ensure_ascii=False),
"warnings": json.dumps([], ensure_ascii=False),
}
```
4. 点"保存"
---
### 5.6 节点 10结束节点
**在 Dify 平台**:点"+",选"结束"。
**填写步骤**
1. **节点标题**`end`(默认)
2. **输出变量**
| 变量名 | 类型 | 引用 |
|---|---|---|
| `variants` | 文本 (string) | `{{ code_merge.variants}}` |
| `warnings` | 文本 (string) | `{{ code_merge.warnings}}` |
| `cutout_url` | 文本 (string) | `{{ start.cutout_url}}` |
3. 点"保存"
---
### 5.7 节点连线
在 Dify 画布上拖拽连线:
```
start → llm_rewrite → (5 路分支)
├──→ gen_prizm_blue ─┐
├──→ gen_pokemon_rainbow ─┤
├──→ gen_topps_refractor ─┼→ code_merge → end
├──→ gen_yugioh_secret ─┤
└──→ gen_ws_ssp ─┘
```
**执行模型(重要)**
- 5 个生图节点是 DAG 上的**独立分支**Dify 调度器**并行执行**它们(不是顺序)
- 总耗时 ≈ **max(5 路) ≈ 10-30 秒**(不是累加 50-150 秒)
- 任一节点失败 → **整个工作流立即终止**fail-fast未配置 Continue
- 工作流总超时 = 默认 60 秒(任意节点超过即挂)
**Dify 平台操作**
- 鼠标悬停在节点边缘,会出现连线点,拖到下一个节点即可
- 5 个生图节点都连到 `code_merge` 的输入
---
### 5.8 测试运行
1. 点画布右上角"调试"按钮
2. 在"用户输入"框直接输入风格描述词(如"梦幻樱花"Dify 会自动注入为 `#context#`
3. 如果用 JSON 输入测试数据:
```json
{
"#context#": "梦幻樱花",
"cutout_url": "https://example.com/test-cutout.png"
}
```
3. 点"开始运行"
4. 查看每个节点的输出:
- LLM 节点:应该是 5 个 prompt 的 JSON耗时 ~3s
- Code 拆分:应该是 5 个独立变量(耗时 < 1s
- 5 个生图节点**并行执行**每张图大概需要 10-30 **总耗时 = 最慢那一路**
- 结束节点variants 数组里有 5 URL
5. 如果某节点报错看节点上的红色叹号查看错误信息
- LLM/Code 节点报错 工作流立即挂掉 variants 输出
- 任一生图节点报错 **整个工作流立即挂掉**fail-fast即使其他 4 路成功了也算白跑
- 失败原因记录在工作流日志里可点击节点查看详细错误
### 5.9 参数附录(小白填表清单)
> 本节把 §5.1-5.8 里散落的参数汇总成一张"填表清单",照着填就行。
#### 5.9.1 AI 在哪步填写(关键)
整个工作流里**两次填 AI**分别在两个不同节点
| 节点 | 填什么 AI | 任务 |
|---|---|---|
| 节点 2LLM 节点 | **gpt-4o-mini**文本模型 | 把用户 1 prompt 扩写为 5 个英文子 prompt |
| 节点 4-85 个生图节点 | **gpt-image-1**图像模型 | 每个节点根据 1 个子 prompt 生成 1 张图 |
**两次 AI 不能合并**文本模型不会画图图像模型不会读 prompt 拆解所以必须 2 类节点
#### 5.9.2 节点 1 · 开始Start
无需参数配置只设**输入变量**
| 参数 | 填什么 | 说明 |
|---|---|---|
| 变量名 | `cutout_url` | 类型"文本"勾必填 |
| ~~变量名~~ | ~~`user_prompt`~~ | **不添加**——Dify 原生 `#context#` 会自动注入用户风格描述词 |
#### 5.9.3 节点 2 · LLM 节点(**填文本 AI**
| 参数 | | 说明 |
|---|---|---|
| 模型 (Model) | `gpt-4o-mini` | **AI 在这填】** 便宜 + 够用想更稳可改 `gpt-4o` 30 |
| 温度 (Temperature) | `0.7` | 5 prompt 有差异化太低0.1 5 个几乎一样 |
| 最大 Token (Max Tokens) | `2000` | 5 prompt ~1500 字符 + buffer |
| Top P | `0.9` | |
| 频率惩罚 | `0.0` | |
| 存在惩罚 | `0.0` | |
| 系统提示 (System Prompt) | [粘贴 §5.2 的整段 prompt] | **关键** |
| 用户提示 (User Prompt) | `#context#`Dify 原生特殊变量 | **直接打 `#context#` 字面量**Dify 平台自动注入用户风格描述词 |
| 输出格式 (Output Format) | `JSON` | **必选 JSON**否则 Code 节点解析失败 |
#### 5.9.4 节点 3 · 代码节点(拆分 JSON
| 参数 | | 说明 |
|---|---|---|
| 执行语言 | `Python 3` | 不要选 Node.js / Go |
| 输入变量 (Input Variables) | `llm_output` | 引用 llm_rewrite 节点输出一般叫 `llm_rewrite.text` `llm_rewrite.output` |
| 代码 (Code) | [粘贴 §5.3 Python 代码] | |
#### 5.9.5 节点 4-8 · 5 个生图节点(**填图像 AI**
每个节点结构相同参数差异只在 Prompt 引用
**节点 4gen_prizm_blue配置示例**
| 参数 | | 说明 |
|---|---|---|
| 模型 (Model) | `gpt-image-1` | **AI 在这填】** |
| 图片尺寸 (Size) | `1024 × 1024` | 方形裁切到 450×600 不变形 |
| 生成数量 (N) | `1` | 每路 1 |
| 质量 (Quality) | `high` | vivid natural 更适合"彩虹金属"风格 |
| 风格 (Style) | `vivid` | |
| 提示词 (Prompt) | `{{ llm_rewrite.prizm_blue}}` | **用 `/` 选变量** |
| 负向提示 (Negative Prompt) | `text, people, watermark, logo, signature, flat, matte, 2D, cartoon` | **必填**否则 AI 容易出文字/水印 |
**5 个节点的差异(只改 Prompt 引用)**
| 节点 | 标题 | Prompt 引用 | preset_id |
|---|---|---|---|
| 4 | `gen_prizm_blue` | `{{ llm_rewrite.prizm_blue}}` | prizm_blue |
| 5 | `gen_pokemon_rainbow` | `{{ llm_rewrite.pokemon_rainbow}}` | pokemon_rainbow |
| 6 | `gen_topps_refractor` | `{{ llm_rewrite.topps_refractor}}` | topps_refractor |
| 7 | `gen_yugioh_secret` | `{{ llm_rewrite.yugioh_secret}}` | yugioh_secret |
| 8 | `gen_ws_ssp` | `{{ llm_rewrite.ws_ssp}}` | ws_ssp |
**Dify 平台操作**节点 4 配置完 右键 复制 改标题和 Prompt 引用共复制 4
**备选图像模型**Dify 平台没有 gpt-image-1
| 模型 | 参数差异 | 备注 |
|---|---|---|
| `dall-e-3` | size `1024×1024` / `1792×1024` / `1024×1792` Style | OpenAI 经典款**不支持图生图** |
| `stable-diffusion-xl` | `negative_prompt` 字段 `guidance_scale: 7.5` | 开源支持图生图 |
| `Replicate flux-dev` | `num_inference_steps: 28``width` / `height` | 开源高质量支持图生图 |
| **MiniMax `image-01` / `image-02`** | 模型字段选 `image-01`size 支持 `1024×1024` / `768×1280`Negative Prompt 不支持隐含规则**不支持图生图** | **国内主流**你当前用 |
| 腾讯混元 / 阿里通义万相 | 按平台文档填 | 国内替代 |
**gpt-image-1 配置(如果你有 OpenAI key推荐**
```
模型gpt-image-1
Prompt{{ llm_rewrite.prizm_blue}}
图片尺寸1024x1024
生成数量1
【关键】image 字段:{{ start.cutout_url}} ← 这个让 AI 把人像融入背景
```
**为什么推荐 gpt-image-1**
- 同时支持文生图 + 图生图image field 直接传原图 URL
- 质量最高OpenAI 训练数据最强
- 输出包含人像 + 金属底 + 风格氛围一次成型
- 但需要 OpenAI API key国内访问需要代理
**MiniMax image-01 配置示例**你当前用纯文生图
```
模型image-01 ← 不是 gpt-image-1
Prompt{{ llm_rewrite.prizm_blue}}
图片尺寸1024x1024
生成数量1
Negative PromptMiniMax 不支持,留空)
【没有 image 字段】← MiniMax 是纯文生图
```
**Dify 插件选择**
1. "+"搜索"图像生成""image generation"
2. **`MiniMax 文生图`**langgenius/minimax/minimax-image 插件)—— **不是** langgenius/minimax/minimaxLLM 插件
3. 配置 MiniMax API key MiniMax 开放平台 https://api.minimax.chat 申请
4. 模型选 `image-01` 或更新的 `image-02`
**与 gpt-image-1 的差异**
| | gpt-image-1 | MiniMax image-01 |
|---|---|---|
| 尺寸选项 | 1024x1024 / 1024x1536 / 1536x1024 / auto | 1024x1024 / 768x1280 |
| Negative Prompt | 支持 | **不支持**隐含规则 |
| 风格 (style) | vivid / natural | 不支持 |
| 出图速度 | ~10-30s/ | ~15-40s/ |
| 质量 | 写实/动漫都好 | 偏动漫风格化 |
| 费用 | 0.5-1/ | 0.1-0.3/ |
| **图生图(图+文)** | **支持**image field | **不支持** |
| 人像融合 | AI 直接处理 | 需后端 compositor 后期叠加 |
> **注意 1**:因为 MiniMax 不支持 Negative Promptspec §5.4 里说的"Negative Prompt 字段填 text, people, watermark..." 实际**对 MiniMax 无效**。需要在 LLM 系统提示的**强制约束**里强调这些词LLM 会让生成的 prompt 本身符合"无文字无人"要求。
> **注意 2**:因为 MiniMax 不支持图生图,**当前链路生成的图没有人像**。如果要"人像 + 镭射背景"融合效果,必须:
> - 换 gpt-image-1推荐
> - 或保留 MiniMax + 用后端 compositor 服务把"AI 出图 + 用户人像"合成(要重启 compositor
#### 5.9.6 节点 9 · 代码节点(汇总)
| 参数 | |
|---|---|
| 执行语言 | `Python 3` |
| 输入变量 (Input Variables) | `gen1` / `gen2` / `gen3` / `gen4` / `gen5`引用 5 个生图节点输出 |
| 代码 (Code) | [粘贴 §5.5 Python 代码] |
#### 5.9.7 节点 10 · 结束End
| 输出变量 | 类型 | 引用 |
|---|---|---|
| `variants` | 文本 (string) | `{{ code_merge.variants}}` |
| `warnings` | 文本 (string) | `{{ code_merge.warnings}}` |
| `cutout_url` | 文本 (string) | `{{ start.cutout_url}}` |
#### 5.9.8 总览:你要在 Dify 平台填的所有内容
**必填 AI2 类)**
| 节点 | AI 模型 | 必填项 |
|---|---|---|
| 节点 2 | `gpt-4o-mini` | 模型 + 系统 prompt + 用户 prompt + 输出格式=JSON |
| 节点 4-8×5 | `gpt-image-1` | 模型 + 尺寸 + 数量 + Prompt + 负向 Prompt |
**必填变量引用10 个 → 改为 7 个,因为不再用 user_prompt 自定义变量)**
| 节点 | 引用变量 |
|---|---|
| 开始 | `cutout_url`只定义 1 用户主题由 Dify 原生 `#context#` 注入 |
| LLM | `#context#` user prompt 字段直接打 `#context#` |
| 代码 split | `llm_rewrite.text` .output |
| 生图 1-5 | `{{ llm_rewrite.prizm_blue}}` ... `{{ llm_rewrite.ws_ssp}}` |
| 代码 merge | `gen1`-`gen5`5 个生图节点输出 |
| 结束 | `{{ code_merge.variants}}` / `{{ code_merge.warnings}}` / `{{ start.cutout_url}}` |
**必填代码2 段 Python**
- §5.3 JSON 拆分代码节点 3
- §5.5 variants 汇总代码节点 9
#### 5.9.9 最常见的 3 个坑
1. **模型选错**
- LLM 节点选了 `gpt-image-1`图像模型不会输出 JSON
- 生图节点选了 `gpt-4o`文本模型不会画图
2. **Prompt 引用忘记改**5 个生图节点复制后Prompt 引用没改 5 张图一模一样
3. **输出格式忘选 JSON**LLM 返回 markdown ` ```json ``` ` 包起来的字符串Code 节点解析失败
---
## 6. 后端适配
### 6.1 handleDifyBlocking 改动
**文件**`backend/gateway/controller/laser_generate_controller.go`
**现状**L270-383 已实现 Dify 阻塞路径能解析 variants 数组
**改动**
- 兼容新 preset_idprizm_blue / pokemon_rainbow / topps_refractor / yugioh_secret / ws_ssp
- `attachMaterialsSnapshot` 中的 `Role: "composite"` 逻辑保持不变
- 不再依赖 `req.RenderConfigs[].GratingConfig`Dify 自己渲不需要后端 grating 参数
### 6.2 前端 useLaserDifyGenerate 改动
**文件**`frontend/composables/useLaserDifyGenerate.js`
**改动**
- `resolveRenderConfigs` 从返回 4 项改成 5
- preset_codes `['v1','v2','v3','v4']` 改成 `['prizm_blue','pokemon_rainbow','topps_refractor','yugioh_secret','ws_ssp']`
- `applySucceeded` 不再追加 original 为第 5 Dify 已经返回 5 张完整的
### 6.3 后端 compositor 服务(核心,**必须保留**
**架构定位**compositor **最终成品图合成器**AI "金属底背景图" compositor "用户人像 + 镭射光效" 最终镭射卡
**职责6 层合成)**
| | 内容 | 来源 |
|---|---|---|
| 1 | AI 艺术背景 | Dify 工作流 AI 节点输出 |
| 2 | 金属反光梯度 | 程序生成 |
| 3 | 彩虹衍射光栅色带 | 程序生成核心镭射效果 |
| 4 | AI 装饰层可选 | Dify 工作流 AI 节点输出 |
| 5 | 光栅 finish胶片颗粒 + 磨砂哑光 | 程序生成 |
| 6 | **人物抠图(最顶层)** | **用户上传的原图cutout_url** |
**关键改动**
- `backend/gateway/service/compositor/*.go` —— **保留并增强**之前标 DEPRECATED 是错的
- `backend/gateway/controller/compositor_controller.go` —— **保留**
- `backend/gateway/router.go:299` 路由保留
**实施** `handleDifyBlocking` 中调用
```go
// 伪代码(具体实现见后续 PR
for i, variant := range dVariants {
composited, err := compositor.Compose(compositor.ComposeRequest{
BackgroundURL: variant.SignedURL,
CutoutURL: req.CutoutURL, // 用户上传的人像
GratingConfig: gratingConfigs[i], // 5 套光栅参数
ExportWidth: 450,
ExportHeight: 600,
VariantIndex: i,
})
if err != nil {
// 单张失败 → 整批失败(按 §9.1 fail-fast
return handleError(c, err)
}
// 上传合成图到 OSS
finalURL := uploadToOSS(composited, variant.OssKey)
dVariants[i].SignedURL = finalURL
dVariants[i].Role = "composite" // 标记是合成图
}
```
**注意**之前 spec compositor 标为 DEPRECATED **错误的决定**——AI 不会完美生成"人像 + 镭射底"必须后端 compositor 合成
### 6.4 LASER_GEN_PROVIDER 默认值
**文件**`backend/gateway/config/config.go:205`
**改动**
```go
Provider: getEnv("LASER_GEN_PROVIDER", "dify"), // 原值 "minimax"
```
---
## 7. 数据库
### 7.1 表结构变更
**无变更**。`laser_card_templates` / `laser_card_instances` / `laser_card_operation_logs` 三张表的 schema 保持不变
### 7.2 数据变更5 条 preset 同步)
把现有的 5 `laser_card_templates` 记录从 dream/classic/holoFull/ice/sunset **更新** prizm_blue/pokemon_rainbow/topps_refractor/yugioh_secret/ws_ssp
```sql
BEGIN;
-- 删除旧 5 条
UPDATE public.laser_card_templates
SET deleted_at = (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
WHERE template_code IN ('dream', 'classic', 'holoFull', 'ice', 'sunset')
AND deleted_at IS NULL;
-- 同步序列
SELECT setval('laser_card_templates_id_seq', (SELECT MAX(id) FROM public.laser_card_templates));
-- 插入新 5 条
INSERT INTO public.laser_card_templates (
template_code, name, status, version,
backdrop_options, render_config,
engine_min_version, sort_order,
star_id, created_by, created_at, updated_at
) VALUES
('prizm_blue', '蓝棱镜', 'published', 1,
'[{"id":"prizm_blue","label":"蓝棱镜","oss_key":""}]'::jsonb,
'{"style":"prizm_blue","family":"panini_prizm","sheen_band_angle":120,"sheen_intensity":0.38,"sheen_speed":0.42,"foil_coverage":0.70,"backdrop_tone":"#A8ACB2"}'::jsonb,
'compositor-1.0.0', 0, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('pokemon_rainbow', '宝可梦彩虹', 'published', 1,
'[{"id":"pokemon_rainbow","label":"宝可梦彩虹","oss_key":""}]'::jsonb,
'{"style":"pokemon_rainbow","family":"pokemon_tcg","sheen_band_angle":100,"sheen_intensity":0.46,"sheen_speed":0.46,"foil_coverage":0.90,"backdrop_tone":"#B0ACA6"}'::jsonb,
'compositor-1.0.0', 1, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('topps_refractor', 'Topps 折射', 'published', 1,
'[{"id":"topps_refractor","label":"Topps 折射","oss_key":""}]'::jsonb,
'{"style":"topps_refractor","family":"topps_chrome","sheen_band_angle":125,"sheen_intensity":0.36,"sheen_speed":0.40,"foil_coverage":0.68,"backdrop_tone":"#A8ACB2"}'::jsonb,
'compositor-1.0.0', 2, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('yugioh_secret', '游戏王 Secret', 'published', 1,
'[{"id":"yugioh_secret","label":"游戏王 Secret","oss_key":""}]'::jsonb,
'{"style":"yugioh_secret","family":"yugioh","sheen_band_angle":90,"sheen_intensity":0.44,"sheen_speed":0.44,"foil_coverage":0.90,"backdrop_tone":"#B0ACA6"}'::jsonb,
'compositor-1.0.0', 3, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint),
('ws_ssp', 'WS SSP', 'published', 1,
'[{"id":"ws_ssp","label":"WS SSP","oss_key":""}]'::jsonb,
'{"style":"ws_ssp","family":"weiss","sheen_band_angle":95,"sheen_intensity":0.48,"sheen_speed":0.48,"foil_coverage":0.95,"backdrop_tone":"#A8ACB2"}'::jsonb,
'compositor-1.0.0', 4, 0, 0,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint);
-- 同步序列
SELECT setval('laser_card_templates_id_seq', (SELECT MAX(id) FROM public.laser_card_templates));
COMMIT;
```
### 7.3 现有 11 条 instance 处理
**决策**不动历史 instance `materials_snapshot.preset_id` 还是 dream/classic 等旧值仅作为历史记录保留新生成的 instance 会用新 preset_id
---
## 8. 前端适配
### 8.1 useLaserDifyGenerate.resolveRenderConfigs 改动
**文件**`frontend/composables/useLaserDifyGenerate.js:103-117`
**新代码**
```js
const PRESET_CODES = [
'prizm_blue',
'pokemon_rainbow',
'topps_refractor',
'yugioh_secret',
'ws_ssp',
]
async function resolveRenderConfigs(_presetIds, userPrompt) {
let prompt = (userPrompt || '').trim().slice(0, 1000)
if (prompt && containsChinese(prompt)) {
console.log('[translate] Chinese detected, translating...')
prompt = await translateToEnglish(prompt)
}
// 5 路并发 AI 生图,每路 1 个卡牌系列Dify 工作流内部按 preset_id 路由)
return PRESET_CODES.map((code) => ({
preset_id: code,
bg_prompt: prompt || 'Transform this into a premium holographic artwork',
}))
}
```
### 8.2 applySucceeded 改动
**文件**`frontend/composables/useLaserDifyGenerate.js:192-207`
**新逻辑**
- 不再追加 original 为第 5 Dify 已经返回完整 5
- variants 数组直接用 Dify 返回的 5
### 8.3 useLaserBatchGenerate 改动
**文件**`frontend/composables/useLaserBatchGenerate.js:115`
**改动**
```js
const genMode = 'dify' // 原值 'openai'
```
### 8.4 LenticularCard 接入 WebGL tilt 叠层(关键)
**目的**用户在前端查看生成的 5 张镭射卡时倾斜手机能看到"色彩随角度变化"的效果
**现有资产**全部保留
| 文件 | 作用 |
|---|---|
| `frontend/components/lenticular/LenticularCard.vue` | 卡片显示容器 |
| `frontend/composables/useLenticularCraftTiltPreview.js` | tilt 输入 + WebGL 引擎包装 |
| `frontend/composables/useLenticularPreview.js` | 引擎 + 模拟 tilt |
| `frontend/utils/lenticular-engine.js` | LenticularEngine 核心 |
| `frontend/utils/webgl/holographic-engine.js` | WebGL 渲染引擎 |
| `frontend/utils/webgl/holographic-shaders.js` | shader 源码 |
**改动点**
1. `LenticularCard.vue` 已支持 `image` prop5 张图作为 `images` 数组传入即可
2. 新生成的图5 preset_id需要映射到引擎所需的 `backdrop_tone` / `sheen_band_angle` 参数
```js
// 新增映射表lenticular-engine.js 或独立 config 文件)
const PRESET_PHYSICS = {
prizm_blue: { metal: '#A8ACB2', angle: 120, intensity: 0.38, speed: 0.42, foil: 0.70 },
pokemon_rainbow: { metal: '#B0ACA6', angle: 100, intensity: 0.46, speed: 0.46, foil: 0.90 },
topps_refractor: { metal: '#A8ACB2', angle: 125, intensity: 0.36, speed: 0.40, foil: 0.68 },
yugioh_secret: { metal: '#B0ACA6', angle: 90, intensity: 0.44, speed: 0.44, foil: 0.90 },
ws_ssp: { metal: '#A8ACB2', angle: 95, intensity: 0.48, speed: 0.48, foil: 0.95 },
}
```
3. `LenticularCard.vue` 根据当前显示图对应的 preset_id 取物理参数喂给 engine
详细 WebGL shader 工作原理和 uniform 配置见 §12
---
### 8.5 用户输入规范(前端 UI 引导)
**核心问题**用户在"生成镭射卡"页面输入什么
#### 8.5.1 输入语义
用户输入的应该是**"想要的镭射卡氛围/风格"**不是 IP 人物名长句子
#### 8.5.2 输入示例(推荐 1-15 字)
| 类型 | 示例 |
|---|---|
| 季节氛围 | 梦幻樱花 / 夏日海风 / 冬日雪景 / 秋日金黄 |
| 自然现象 | 星空极光 / 银河流星 / 极光之夜 / 晨雾森林 |
| 风格美学 | 赛博朋克 / 蒸汽朋克 / 复古胶片 / 极简主义 |
| 色彩情绪 | 玫瑰金之夜 / 午夜蓝调 / 香槟暖阳 / 翡翠冷光 |
| 节日主题 | 圣诞夜空 / 新年烟花 / 情人节心动 |
#### 8.5.3 完整示例:输入"梦幻樱花"会得到什么
**用户输入**`梦幻樱花`
**5 张图都是"梦幻樱花"主题,但工艺不同**用户对比"哪种光效最适合这张图"
| # | preset_id | 英文 prompt喂给 gpt-image-1 | 视觉特征 |
|---|---|---|---|
| 1 | prizm_blue | `dreamy cherry blossom, on cool silver #A8ACB2 metallic card substrate, electric blue sapphire accent, sharp reflective sport card mood, centered subject, no text, no people, no watermark` | 锐利反射 + 电光蓝 + 樱花元素 |
| 2 | pokemon_rainbow | `dreamy cherry blossom, on pearl silver #B0ACA6 metallic card substrate, soft luminous anime glow, premium Japanese TCG mood, centered chibi-style subject, no text, no people, no watermark` | 日系动漫 + 闪光粒子 + kawaii 樱花 |
| 3 | topps_refractor | `dreamy cherry blossom, on cool silver #A8ACB2 metallic card substrate, vintage baseball card mood, soft film grain texture, classic portrait composition, no text, no people, no watermark` | 复古肖像 + 胶片颗粒 + 樱花 |
| 4 | yugioh_secret | `dreamy cherry blossom, on pearl silver #B0ACA6 metallic card substrate, full-card luminous anime art, magic circle elements, dynamic action pose, no text, no people, no watermark` | 全卡横纹 + 魔法阵 + 樱花 |
| 5 | ws_ssp | `dreamy cherry blossom, on cool silver #A8ACB2 metallic card substrate, premium anime collectible apex mood, signature-style framed composition, no text, no people, no watermark` | 签名框 + 樱花 + 终极收藏感 |
**前端展示效果**5 张图横向排列在同一页面用户能一眼对比"哪个光效最适合"。点选 1 走铸造流程
**关键设计**5 张图**主题完全一致**只是**工艺/金属底/光线/构图**不同这才是"5 张差异化"——而不是 5 张完全不同主题的图
#### 8.5.4 反例(不应该输入)
| 反例 | 为什么不合适 |
|---|---|
| "宝可梦" | 这是 IP 不是风格 |
| "我的照片" | 不是风格描述 |
| "生成一张好看的卡" | 太抽象没具体氛围 |
| "樱花海浪星空混合" | 太长> 15 字AI 难聚焦 |
| "Pikachu 在草地上玩耍" | 叙事场景,不是风格 |
#### 8.5.5 前端 UI 实现
`lenticular-create.vue` 页面:
```vue
<template>
<view class="prompt-input">
<textarea
v-model="userPrompt"
:placeholder="placeholder"
:maxlength="15"
@input="onInput"
/>
<view class="hot-chips">
<view
v-for="chip in hotChips"
:key="chip"
class="chip"
@click="userPrompt = chip"
>
{{ chip }}
</view>
</view>
</view>
</template>
<script setup>
const placeholder = '输入想要的镭射卡风格,如「梦幻樱花」'
const userPrompt = ref('')
const hotChips = ['梦幻樱花', '星空极光', '赛博朋克', '玫瑰金之夜', '复古胶片']
function onInput() {
// 限制 1-15 字
if (userPrompt.value.length > 15) {
userPrompt.value = userPrompt.value.slice(0, 15)
}
}
</script>
```
**关键 UI 元素**
1. **placeholder 文案**:明确告诉用户"输入风格,不是 IP 名"
2. **5 个热门 chip**:点击即填入,覆盖 80% 场景
3. **maxlength=15**:硬限制,避免超长输入
4. **后端兜底**:服务端二次校验(见 §8.5.5
#### 8.5.6 后端校验(兜底)
如果用户绕过前端直接调 API输入了不合适的内容如"Pokemon"或长段落),后端 `handleDifyBlocking` 不做特殊处理——直接转发给 Dify。LLM 会按 system prompt 处理,可能产生不可预期输出但不报错。
**不做强校验**:因为:
- 限制太死会影响用户体验
- 不可预期输出也是"风格化"的一种表现
- D2 灰度时观察用户实际输入数据再决定是否加严
---
## 9. 错误处理
### 9.1 整体策略fail-fast任一节点失败 → 整个工作流失败)
**Dify 工作流默认错误传播模型**:任何节点报错 → 整个工作流立即中断 → 后端拿到 `status: failed`
**5 个生图节点不开启 "Continue on Error"**——任一生图节点失败就整体失败,前端引导用户重试。
| 场景 | 行为 | 前端展示 |
|---|---|---|
| LLM 节点返回非 JSON | Code 节点抛异常 → 工作流挂掉 | 整体失败,弹"AI 主题扩写失败,请重试" |
| **任一生图节点失败**4 种原因) | 工作流立即挂掉5 路全废 | 整体失败,弹"AI 生图失败X/Y 张),请重试" |
| Code merge 节点失败 | 工作流挂掉 | 整体失败,弹"图床合并失败,请重试" |
| 整体超时(> 60s | Dify 平台 timeout | 整体失败,弹"生成超时,请重试" |
| 后端 OSS 上传失败(理论上不发生) | controller 写 ERROR 日志 | 弹"图床上传失败,请重试" |
### 9.2 单路失败的 4 种原因
| 原因 | 处理 |
|---|---|
| gpt-image-1 API 限流 | Dify 节点内置重试 1 次;不解决则整体失败 |
| gpt-image-1 内容审核拒绝 | 整体失败,前端引导"换个主题试试" |
| 网络抖动 | Dify 节点内置重试 1 次 |
| AI 出图时长 > 60s | Dify 节点 timeout整体失败 |
### 9.3 与前端用户体验的配合
- **整体失败 = 重试按钮**(不是"用成功的那几张"
- 重试 = 调 `useLaserDifyGenerate.submit()` 重新走工作流
- 不会"部分成功",用户每次看到 5 张完整图 或 0 张完整图
---
## 10. 测试
### 10.1 单元测试
| 测试 | 工具 |
|---|---|
| useLaserDifyGenerate.resolveRenderConfigs 返回 5 项 | Vitest |
| useLaserDifyGenerate.applySucceeded 不再追加 original | Vitest |
| handleDifyBlocking 解析新 preset_id | Go test |
### 10.2 集成测试
| 场景 | 步骤 |
|---|---|
| 完整链路 | 上传图 → 输入 prompt → 调 Dify → 收到 5 张 → 选 1 → 铸造 |
| 整体失败fail-fast 验证) | Mock Dify 让任一生图节点返回 error → 整个工作流失败 → 后端 status=failed → 前端展示"AI 生图失败,请重试" |
| 重试生效 | Mock Dify 让生图节点首次 fail + 重试 succeed → 5 张图正常返回 |
| 主题差异 | 同一图不同 prompt樱花 vs 海洋 vs 城市)→ 5 张主题不同 |
| 并行执行验证 | 5 路生图节点总耗时 ≈ 最慢一路(不是累加 50s+ |
### 10.3 灰度
1. **D1**:内部测试账号 100% 流量
2. **D2**:白名单 5% 真实用户
3. **D3**:全量(观察 1 周)
---
## 11. 风险与回滚
| 风险 | 缓解 | 回滚 |
|---|---|---|
| Dify 工作流配置错误 | 详细小白指南 + 调试模式 | 把 `LASER_GEN_PROVIDER` 改回 `openai` |
| AI 出图质量不稳定 | D2 灰度观察 | 同上 |
| Dify 平台不稳定 | 监控告警 + 备用 OpenAI provider | 同上 |
| 5 个 preset_id 不被前端识别 | 保留兼容逻辑(旧 preset_id 也映射) | 暂时回退到旧 preset |
| **Dify 代码节点 sandbox 连不上**`Name or service not known` | Dify 部署时确保 sandbox 容器在跑 | 见下方 §11.1 排查步骤 |
### 11.1 Dify sandbox 服务排查
#### 现象 Asandbox 容器连不上
**报错**`Failed to execute code ... Error: [Errno -2] Name or service not known`
**原因**Dify 代码沙箱sandbox 容器)连不上或没启动
**排查命令**
```bash
# 1. 检查 sandbox 容器状态
docker ps | grep sandbox
# 2. 查看日志
docker logs dify-sandbox-1
# 或
docker logs sandbox-1
# 3. 测试网络连通
docker exec dify-api-1 ping sandbox
# 4. 重启 sandbox
docker compose -f docker/docker-compose.yaml restart sandbox
# 5. 如果还是不行,看 docker-compose 是否漏写 sandbox
grep -A5 "sandbox:" docker/docker-compose.yaml
```
#### 现象 BPython 启动崩溃seccomp abort
**报错**
```
main.DifySeccomp(...ate filter
goroutine 17 [running, locked to thread]:
main.DifySeccomp(...)
/home/runner/work/dify-sandbox/dify-sandbox/cmd/lib/python/main.go:11
error: signal: aborted (core dumped)
```
**根因**`docker/dify-deploy.sh` 默认下载 **Dify v0.15.0**,其 sandbox 镜像有 seccomp 限制 bug启动 Python 子进程时 abort。
**修复**:升级 Dify 到 v1.x已修复 seccomp
**完整升级步骤**(在 `101.132.250.62` 上):
```bash
# 0. SSH 登录
ssh root@101.132.250.62
cd /opt/dify/docker
# 1. 备份关键v1.x 不兼容 v0.15.x workflow
cp .env .env.bak.$(date +%Y%m%d)
docker exec dify-postgres-1 pg_dump -U postgres dify > /tmp/dify_db_$(date +%Y%m%d).sql
# 2. 停掉旧服务(不要加 -v会删数据卷
docker compose down
# 3. 拉新版本 docker-compose
curl -L https://raw.githubusercontent.com/langgenius/dify/1.4.0/docker/docker-compose.yaml \
-o docker-compose.yml
# 4. 拉新 .env.example保留现有 .env 不动)
curl -L https://raw.githubusercontent.com/langgenius/dify/1.4.0/docker/.env.example \
-o .env.example
# 5. 把 .env.example 新增的变量补到 .env保留旧值
diff .env .env.example | head -50
cat .env > .env.new
# 编辑 .env.new把 .env.example 新增的变量加进去(默认值即可)
mv .env.new .env
# 6. 拉新镜像
docker compose pull
# 7. 启动首次启动会跑数据库迁移1-3 分钟)
docker compose up -d
# 8. 看迁移日志
docker compose logs -f api 2>&1 | head -100
# 9. 健康检查
curl -s -o /dev/null -w '%{http_code}' http://localhost:5001/health
# 应该返回 200
# 10. 检查 sandbox 修复
docker ps | grep sandbox
docker logs dify-sandbox-1 --tail 50
# 不应该有 DifySeccomp abort 报错
```
**v1.4.0 新增关键环境变量**(如果 .env 缺失需手动补):
```bash
# 加到 .env 文件末尾
echo "CONSOLE_API_URL=http://localhost:8084/v1" >> .env
echo "CONSOLE_WEB_URL=http://localhost:8084" >> .env
echo "SERVICE_API_URL=http://localhost:8083/v1" >> .env
echo "APP_API_URL=http://localhost:8083/v1" >> .env
echo "APP_WEB_URL=http://localhost:8083" >> .env
echo "FILES_URL=http://localhost:8083" >> .env
echo "MIGRATION_ENABLED=true" >> .env
echo "SANDBOX_PORT=8194" >> .env
echo "SANDBOX_API_KEY=dify-sandbox" >> .env
```
**升级后必须做**
1. **重新导入 workflow**v0.15.x yml 不兼容 v1.x
- 在 Dify 控制台 `http://101.132.250.62:8084` 手动搭建 `laser_card_v2_5series` 工作流
- 或让 Claude 按 §3 / §5 步骤远程协助配置
2. **验证 sandbox 修复**
- Dify 控制台"调试"模式跑工作流
- 看 5 个生图节点是否各拿到 1 张图
3. **测试后端调用**
```bash
curl -X POST http://localhost:8083/v1/workflows/run \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"inputs": {"#context#": "梦幻樱花"}, "user": "test-user"}'
```
**回滚步骤**(如果迁移失败):
```bash
cd /opt/dify/docker
docker compose down
# 恢复旧版本
cp docker-compose.yml.bak docker-compose.yml
cp .env.bak.20260101 .env
# 启动旧版本
docker compose up -d
# 数据库回滚
docker exec -i dify-postgres-1 psql -U postgres dify < /tmp/dify_db_20260101.sql
```
#### 现象 CDify 平台未部署 / 找不到 sandbox
**排查**Dify 可能在另一台服务器部署。
```bash
# 1. 确认 Dify 实际部署在哪
grep -E "SERVER_HOST|SERVER_PATH" /Users/liulujian/Documents/code/TopFansByGithub/docker/dify-deploy.sh
# 默认是 SERVER_HOST="101.132.250.62"
# 2. SSH 到 Dify 服务器排查
ssh root@101.132.250.62
docker ps | grep -iE "sandbox|dify"
```
**修复后**:在 Dify 工作流"调试"按钮重新跑一次,确认 5 个生图节点各输出 1 张图。
---
## 12. 前端展示层WebGL tilt 实时渲染)
> **本节为新增加章节**。spec §3 / §8 仅覆盖"AI 出图 + 落库",本节专门讲"前端展示时的彩虹随角度变化怎么实现"。
### 12.1 设计目标
| 维度 | 目标 |
|---|---|
| 倾斜角度 | 用户左/右/上/下倾斜手机(-1..+1 归一化) |
| 色彩响应 | 彩虹色带方向、高光位置、强度随 viewAngle 偏移 |
| 性能 | 60fps移动端 GPU 需 < 5ms / |
| 兼容 | iOS Safari / Android Chrome / 微信内置 / H5 |
### 12.2 技术栈(已存在 + 复用)
| | 文件 | 角色 |
|---|---|---|
| 输入 | `useLenticularCraftTiltPreview.js` | 陀螺仪 / 触摸 归一化 tilt 输入 |
| 引擎 | `utils/lenticular-engine.js` | LenticularEngine 物理参数 + sensor 输入 |
| 渲染 | `utils/webgl/holographic-engine.js` | WebGL 上下文 + uniform 写入 |
| Shader | `utils/webgl/holographic-shaders.js` | GLSL 源码 rainbow 计算 |
| 容器 | `components/lenticular/LenticularCard.vue` | Vue 组件绑定 canvas |
**所有层已存在,本次只做参数接线,不重写引擎。**
### 12.3 Shader 关键 uniform
**文件**`frontend/utils/webgl/holographic-shaders.js`
| uniform | 类型 | 来源 | 作用 |
|---|---|---|---|
| `u_viewAngle` | vec3 | tilt (dx, dy, dz) | 视角方向 决定色带偏移 |
| `u_lightDir` | vec3 | 固定 (0, 0, 1) | 光源方向屏幕外 |
| `u_dispersionStrength` | float | preset.foil_coverage | 色散强度彩虹宽度 |
| `u_highlightSpeed` | float | preset.sheen_speed | 高光移动速度 |
| `u_highlightWidth` | float | preset.sheen_intensity | 高光带宽度 |
| `u_baseImage` | sampler2D | AI 出的静态图 | 底图 |
| `u_spectrumRamp` | sampler2D | 预生成彩虹 LUT | 彩虹色查找表 |
**关键公式**fragment shader
```glsl
// 把 viewAngle 投影到屏幕空间 → 计算色带偏移量
vec2 offsetDir = normalize(u_viewAngle.xy);
float offsetAmt = length(u_viewAngle.xy) * u_dispersionStrength;
// 在 uvs 上加偏移 → 采样 spectrum ramp
vec2 rampUV = v_uv + offsetDir * offsetAmt;
vec3 rainbow = texture(u_spectrumRamp, rampUV).rgb;
// 高光带1D 投影
float highlightPos = dot(v_uv, offsetDir);
float highlight = smoothstep(u_highlightWidth, 0.0, abs(highlightPos - 0.5));
rainbow += vec3(highlight) * u_highlightSpeed;
// 屏幕混合模式
vec3 finalColor = baseColor + rainbow * 0.5;
gl_FragColor = vec4(finalColor, 1.0);
```
### 12.4 5 个 preset 的物理参数映射
| preset_id | metal hex | sheen angle | intensity | speed | foil |
|---|---|---|---|---|---|
| `prizm_blue` | `#A8ACB2` | 120° | 0.38 | 0.42 | 0.70 |
| `pokemon_rainbow` | `#B0ACA6` | 100° | 0.46 | 0.46 | 0.90 |
| `topps_refractor` | `#A8ACB2` | 125° | 0.36 | 0.40 | 0.68 |
| `yugioh_secret` | `#B0ACA6` | 90° | 0.44 | 0.44 | 0.90 |
| `ws_ssp` | `#A8ACB2` | 95° | 0.48 | 0.48 | 0.95 |
**映射表存放位置**`frontend/utils/lenticular-engine.js` 内新增 `PRESET_PHYSICS_MAP` 常量
### 12.5 tilt 输入流
```
[陀螺仪/触摸]
DeviceOrientationEvent / touchstart/touchmove
useLenticularCraftTiltPreview.simulateTilt(dx, dy)
↓ (归一化 -1..+1)
LenticularEngine.feedSensor({x, y, z})
↓ (物理平滑 + 滤波)
LenticularEngine.computeRenderState()
{ viewAngle: vec3, displacement: vec2 }
holographic-engine.setUniforms({ u_viewAngle, ... })
WebGL draw call → 60fps 输出
```
### 12.6 与既有 LenticularCard 集成
**当前状态**`LenticularCard.vue` 已支持 tilt 渲染但绑定的物理参数是写死的
**改动** `LenticularCard.vue` 接受 `presetId` prop根据 presetId `PRESET_PHYSICS_MAP` 取物理参数喂给 LenticularEngine
```vue
<!-- LenticularCard.vue -->
<script setup>
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview'
import { PRESET_PHYSICS_MAP } from '@/utils/lenticular-engine'
const props = defineProps({
image: { type: String, required: true },
presetId: { type: String, default: 'prizm_blue' },
})
const physics = PRESET_PHYSICS_MAP[props.presetId] || PRESET_PHYSICS_MAP.prizm_blue
const preview = useLenticularCraftTiltPreview({
canvasId: props.canvasId,
baseImage: props.image,
physics, // ← 新增 prop
})
</script>
```
### 12.7 失败兜底
| 场景 | 兜底 |
|---|---|
| WebGL 上下文创建失败老旧设备 | 降级到 `<img>` 静态展示 |
| 陀螺仪 API 不可用H5 / 微信内置 | 触摸滑块控制 tilt |
| shader 编译失败 | 控制台日志 + 静态展示 |
### 12.8 性能预算
- **单帧时间**< 5ms移动端中端机型
- **shader 复杂度**限制在 20 ALU 指令以内
- **纹理数量**:≤ 3baseImage + spectrumRamp + 可选 scratchMap
---
## 13. 待用户审 spec 的问题
> 在开始 writing-plans 前,需要你确认:
1. **设计方向**路径 CDify 工作流+ 5 系列 × 风格描述词 + **AI 出金属底卡 + 前端 WebGL tilt 实时叠彩虹**是否符合预期
2. **preset_id 命名**prizm_blue / pokemon_rainbow / topps_refractor / yugioh_secret / ws_ssp可以接受吗
3. **数据库 5 条模板** §7.2 SQL 直接覆盖现有的 dream/classic/holoFull/ice/sunset可以接受吗
4. **后端 compositor**本次只标记 DEPRECATED保留供调试删除放在后续 PR可以接受吗
5. **灰度策略** 100% 内部 5% 白名单 全量可以接受吗
6. **Dify 平台 + 模型**
- 你的 Dify 平台是否已部署是否支持 gpt-image-1如不支持告诉我备选
- LLM 节点用 `gpt-4o-mini` 还是 `minimax-m3`你实际在用的
7. **前端 WebGL tilt**是否接受 §12 的方案——LenticularEngine 复用 + PRESET_PHYSICS_MAP 映射 + LenticularCard 改造
8. **AI prompt 调整**是否接受 §5.2 新的 LLM prompt——强调金属底严禁 holographic/rainbow 等词
9. **用户输入规范**是否接受 §8.5 的方案——前端输入框 placeholder + 5 个热门风格 chip + maxlength=15