feat: 接入微达API中转站,重构镭射卡生图流程
- 替换中转站从 xbcl.link 到 weda.cc - prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖) - 4 路并发调用 + 原图展示 = 5 张 variant - 前端提示词中译英支持 - 全局 Vue errorHandler - WebSocket 鉴权失败跳登录 - 删除已弃用的 laserCompositor 微服务 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d9473fda7a
commit
af7908e72e
@ -81,9 +81,26 @@ WS_AI_CHAT_PATH=/ai-chat
|
|||||||
|
|
||||||
# ==================== Dify AI Workflow ====================
|
# ==================== Dify AI Workflow ====================
|
||||||
# Dify API 地址(自部署或云服务)
|
# Dify API 地址(自部署或云服务)
|
||||||
DIFY_API_BASE=https://api.dify.ai/v1
|
DIFY_API_BASE=http://localhost/v1
|
||||||
# Dify App API Key(laser_card_variants_v1 工作流)
|
# Dify App API Key(laser_card_variants_v1 工作流)
|
||||||
DIFY_API_KEY=
|
DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
|
||||||
|
# Dify 工作流名称(relay-dify 模式时使用的 Dify app,用于 prompt 增强)
|
||||||
|
DIFY_WORKFLOW=laser_prompt_enhancer_v2
|
||||||
|
# ==================== 镭射卡生成器 ====================
|
||||||
|
# LASER_GEN_PROVIDER:
|
||||||
|
# minimax (默认) - 后端直连 MiniMax, 并行 5 variant 生成背景+装饰, 再调 compositor 合成
|
||||||
|
# dify - 调 Dify laser_card_variants_v1 工作流, 由 Dify 内部分发
|
||||||
|
# openai - 后端直连 OpenAI /v1/images/edits, 5 路并发 + 直接落 OSS (不调 compositor)
|
||||||
|
# relay-dify - Dify 增强 prompt → 中转站 edits → 落 OSS(单张调试,可扩展)
|
||||||
|
LASER_GEN_PROVIDER=openai
|
||||||
|
|
||||||
|
# ==================== OpenAI Images API (LASER_GEN_PROVIDER=openai 时使用) ====================
|
||||||
|
# 必填:OpenAI API Key(去 https://platform.openai.com/api-keys 生成)
|
||||||
|
OPENAI_API_KEY=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
|
||||||
|
# 可选:API 端点(默认官方地址;如使用代理或自建兼容服务可改)
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# 可选:模型名(默认 gpt-image-1.5;可选 gpt-image-1 / dall-e-3)
|
||||||
|
OPENAI_MODEL=gpt-image-1.5
|
||||||
# laser-compositor 内网地址
|
# laser-compositor 内网地址
|
||||||
# 注意:7000 端口在 macOS 上被 AirPlay Receiver 占用,因此改用 7002
|
# 注意:7000 端口在 macOS 上被 AirPlay Receiver 占用,因此改用 7002
|
||||||
LASER_COMPOSITOR_URL=http://127.0.0.1:7002
|
LASER_COMPOSITOR_URL=http://127.0.0.1:7002
|
||||||
|
|||||||
@ -15,6 +15,8 @@ type Config struct {
|
|||||||
Segment SegmentConfig
|
Segment SegmentConfig
|
||||||
Dify DifyConfig
|
Dify DifyConfig
|
||||||
Minimax MinimaxConfig
|
Minimax MinimaxConfig
|
||||||
|
OpenAI OpenAIConfig
|
||||||
|
LaserGen LaserGenConfig
|
||||||
LaserCompositor LaserCompositorConfig
|
LaserCompositor LaserCompositorConfig
|
||||||
Redis RedisConfig
|
Redis RedisConfig
|
||||||
DB DBConfig
|
DB DBConfig
|
||||||
@ -22,6 +24,15 @@ type Config struct {
|
|||||||
Root string
|
Root string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LaserGenConfig 镭射卡生成器配置
|
||||||
|
// Provider:
|
||||||
|
// "minimax" (默认) - 后端直连 MiniMax + 并行 5 variant + compositor 合成
|
||||||
|
// "dify" - 调 Dify 工作流, 由 Dify 内部分发与合成, 阻塞模式
|
||||||
|
// "openai" - 后端直连 OpenAI Images API /v1/images/edits, 5 路并发 + 直接落 OSS
|
||||||
|
type LaserGenConfig struct {
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
// MinimaxConfig MiniMax 图像生成配置
|
// MinimaxConfig MiniMax 图像生成配置
|
||||||
type MinimaxConfig struct {
|
type MinimaxConfig struct {
|
||||||
APIKey string // MiniMax API 密钥
|
APIKey string // MiniMax API 密钥
|
||||||
@ -35,8 +46,17 @@ type LaserCompositorConfig struct {
|
|||||||
|
|
||||||
// DifyConfig Dify 工作流配置
|
// DifyConfig Dify 工作流配置
|
||||||
type DifyConfig struct {
|
type DifyConfig struct {
|
||||||
APIBase string
|
APIBase string
|
||||||
APIKey string
|
APIKey string
|
||||||
|
Workflow string // 工作流名称/ID,默认 laser_prompt_enhancer_v2
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIConfig OpenAI Images API 配置
|
||||||
|
// 仅在 LASER_GEN_PROVIDER=openai 时使用
|
||||||
|
type OpenAIConfig struct {
|
||||||
|
APIKey string // 必填
|
||||||
|
BaseURL string // 默认 https://api.openai.com/v1
|
||||||
|
Model string // 默认 gpt-image-1.5
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedisConfig Redis 配置
|
// RedisConfig Redis 配置
|
||||||
@ -155,13 +175,22 @@ func Load() *Config {
|
|||||||
InferenceURL: getEnv("SEGMENT_INFERENCE_URL", ""),
|
InferenceURL: getEnv("SEGMENT_INFERENCE_URL", ""),
|
||||||
},
|
},
|
||||||
Dify: DifyConfig{
|
Dify: DifyConfig{
|
||||||
APIBase: getEnv("DIFY_API_BASE", ""),
|
APIBase: getEnv("DIFY_API_BASE", ""),
|
||||||
APIKey: getEnv("DIFY_API_KEY", ""),
|
APIKey: getEnv("DIFY_API_KEY", ""),
|
||||||
|
Workflow: getEnv("DIFY_WORKFLOW", "laser_prompt_enhancer_v2"),
|
||||||
},
|
},
|
||||||
Minimax: MinimaxConfig{
|
Minimax: MinimaxConfig{
|
||||||
APIKey: getEnv("MINIMAX_API_KEY", ""),
|
APIKey: getEnv("MINIMAX_API_KEY", ""),
|
||||||
APIURL: getEnv("MINIMAX_API_URL", "https://api.minimaxi.com/v1/image_generation"),
|
APIURL: getEnv("MINIMAX_API_URL", "https://api.minimaxi.com/v1/image_generation"),
|
||||||
},
|
},
|
||||||
|
OpenAI: OpenAIConfig{
|
||||||
|
APIKey: getEnv("OPENAI_API_KEY", ""),
|
||||||
|
BaseURL: getEnv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
||||||
|
Model: getEnv("OPENAI_MODEL", "gpt-image-1.5"),
|
||||||
|
},
|
||||||
|
LaserGen: LaserGenConfig{
|
||||||
|
Provider: getEnv("LASER_GEN_PROVIDER", "minimax"),
|
||||||
|
},
|
||||||
LaserCompositor: LaserCompositorConfig{
|
LaserCompositor: LaserCompositorConfig{
|
||||||
URL: getEnv("LASER_COMPOSITOR_URL", "http://127.0.0.1:7000"),
|
URL: getEnv("LASER_COMPOSITOR_URL", "http://127.0.0.1:7000"),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@ -16,17 +18,31 @@ import (
|
|||||||
"github.com/topfans/backend/gateway/pkg/response"
|
"github.com/topfans/backend/gateway/pkg/response"
|
||||||
"github.com/topfans/backend/gateway/repository"
|
"github.com/topfans/backend/gateway/repository"
|
||||||
"github.com/topfans/backend/gateway/service"
|
"github.com/topfans/backend/gateway/service"
|
||||||
|
"github.com/topfans/backend/gateway/service/compositor"
|
||||||
"github.com/topfans/backend/pkg/database"
|
"github.com/topfans/backend/pkg/database"
|
||||||
"github.com/topfans/backend/pkg/logger"
|
"github.com/topfans/backend/pkg/logger"
|
||||||
"github.com/topfans/backend/pkg/models"
|
"github.com/topfans/backend/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LaserCardPersister 镭射卡持久化最小接口
|
||||||
|
// controller 只通过此接口访问 DB,便于单元测试用 fake 注入。
|
||||||
|
// 生产实现:*repository.LaserCardRepository(已隐式实现,无需 adapter)
|
||||||
|
type LaserCardPersister interface {
|
||||||
|
FindTemplateByCode(code string) (*models.LaserCardTemplate, error)
|
||||||
|
CreateInstance(inst *models.LaserCardInstance) error
|
||||||
|
UpdateMaterialsSnapshot(instanceID int64, snapshot models.MaterialsSnapshot) error
|
||||||
|
CreateOperationLogSimple(instanceID int64, instanceNo string, userID int64, action, statusBefore, statusAfter string) error
|
||||||
|
}
|
||||||
|
|
||||||
// LaserGenerateController 镭射卡 AI 生成控制器
|
// LaserGenerateController 镭射卡 AI 生成控制器
|
||||||
type LaserGenerateController struct {
|
type LaserGenerateController struct {
|
||||||
minimaxClient *service.MinimaxClient
|
minimaxClient *service.MinimaxClient
|
||||||
compositorClient *service.CompositorClient
|
difyClient *service.DifyClient
|
||||||
|
openaiClient *service.OpenAIClient
|
||||||
jobStore *jobStore
|
jobStore *jobStore
|
||||||
laserRepo *repository.LaserCardRepository
|
laserRepo LaserCardPersister
|
||||||
|
ossHelper *service.OssHelper
|
||||||
|
provider string // "minimax" | "dify" | "openai" | "relay-dify",由 cfg.LaserGen.Provider 决定
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobStore 内存中维护 job 状态
|
// jobStore 内存中维护 job 状态
|
||||||
@ -58,11 +74,26 @@ var globalJobStore = &jobStore{
|
|||||||
|
|
||||||
// NewLaserGenerateController 创建控制器
|
// NewLaserGenerateController 创建控制器
|
||||||
func NewLaserGenerateController(cfg *config.Config) *LaserGenerateController {
|
func NewLaserGenerateController(cfg *config.Config) *LaserGenerateController {
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(cfg.LaserGen.Provider))
|
||||||
|
if provider == "" {
|
||||||
|
provider = "minimax"
|
||||||
|
}
|
||||||
|
logger.Logger.Info("LaserGenerateController initialized",
|
||||||
|
zap.String("provider", provider),
|
||||||
|
zap.String("openai_model", cfg.OpenAI.Model),
|
||||||
|
zap.Bool("openai_key_set", cfg.OpenAI.APIKey != ""),
|
||||||
|
zap.Bool("dify_key_set", cfg.Dify.APIKey != ""),
|
||||||
|
zap.String("dify_workflow", cfg.Dify.Workflow),
|
||||||
|
)
|
||||||
|
|
||||||
return &LaserGenerateController{
|
return &LaserGenerateController{
|
||||||
minimaxClient: service.NewMinimaxClient(cfg.Minimax.APIURL, cfg.Minimax.APIKey),
|
minimaxClient: service.NewMinimaxClient(cfg.Minimax.APIURL, cfg.Minimax.APIKey),
|
||||||
compositorClient: service.NewCompositorClient(cfg.LaserCompositor.URL),
|
difyClient: service.NewDifyClient(cfg.Dify.APIBase, cfg.Dify.APIKey),
|
||||||
jobStore: globalJobStore,
|
openaiClient: service.NewOpenAIClient(cfg.OpenAI.BaseURL, cfg.OpenAI.APIKey, cfg.OpenAI.Model),
|
||||||
laserRepo: repository.NewLaserCardRepository(database.GetDB()),
|
jobStore: globalJobStore,
|
||||||
|
laserRepo: repository.NewLaserCardRepository(database.GetDB()),
|
||||||
|
ossHelper: service.NewOssHelper(cfg.OSS),
|
||||||
|
provider: provider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +138,24 @@ func (ctrl *LaserGenerateController) CreateGenerateJob(c *gin.Context) {
|
|||||||
starID = starIDRaw.(int64)
|
starID = starIDRaw.(int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dify 提供方: 阻塞模式, 同步调 Dify 工作流, 直接返回 variants
|
||||||
|
if ctrl.provider == "dify" {
|
||||||
|
ctrl.handleDifyBlocking(c, userID.(int64), starID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// relay-dify 提供方: Dify LLM 增强 prompt → 中转站 edits → 落 OSS(单张调试)
|
||||||
|
if ctrl.provider == "relay-dify" {
|
||||||
|
ctrl.handleRelayDifySingle(c, userID.(int64), starID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 提供方: 阻塞模式, 5 路并发调 /v1/images/edits, 落 OSS 后直接返回 variants
|
||||||
|
if ctrl.provider == "openai" {
|
||||||
|
ctrl.handleOpenAIDirect(c, userID.(int64), starID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
variants := parseVariants(req)
|
variants := parseVariants(req)
|
||||||
|
|
||||||
jobID := uuid.New().String()
|
jobID := uuid.New().String()
|
||||||
@ -117,43 +166,10 @@ func (ctrl *LaserGenerateController) CreateGenerateJob(c *gin.Context) {
|
|||||||
if len(req.PresetCodes) > 0 {
|
if len(req.PresetCodes) > 0 {
|
||||||
templateCode = req.PresetCodes[0]
|
templateCode = req.PresetCodes[0]
|
||||||
}
|
}
|
||||||
|
// 创建 instance + 写创建日志(统一走 persistGeneratedInstance)
|
||||||
var instanceID int64
|
var instanceID int64
|
||||||
var instanceNoResp string
|
var instanceNoResp string
|
||||||
if ctrl.laserRepo != nil {
|
instanceID, instanceNoResp = ctrl.persistGeneratedInstance(userID.(int64), starID, templateCode, jobID)
|
||||||
renderedInst := &models.LaserCardInstance{
|
|
||||||
InstanceNo: "", // BeforeCreate 自动生成
|
|
||||||
InstanceUlid: "", // BeforeCreate 自动生成
|
|
||||||
TemplateID: 0, // 种子数据 id=1-5,按 code 查找
|
|
||||||
TemplateCode: templateCode,
|
|
||||||
TemplateVersion: 1,
|
|
||||||
OwnerUserID: userID.(int64),
|
|
||||||
StarID: starID,
|
|
||||||
Status: models.LaserCardInstanceStatusRendered,
|
|
||||||
MaterialsSnapshot: models.MaterialsSnapshot{},
|
|
||||||
}
|
|
||||||
// 尝试按 template_code 查到真实 template_id
|
|
||||||
if tpl, err := ctrl.laserRepo.FindTemplateByCode(templateCode); err == nil {
|
|
||||||
renderedInst.TemplateID = tpl.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.laserRepo.CreateInstance(renderedInst); err != nil {
|
|
||||||
logger.Logger.Warn("Failed to persist laser_card_instance on create",
|
|
||||||
zap.String("job_id", jobID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
instanceID = renderedInst.ID
|
|
||||||
instanceNoResp = renderedInst.InstanceNo
|
|
||||||
_ = ctrl.laserRepo.CreateOperationLogSimple(
|
|
||||||
renderedInst.ID, renderedInst.InstanceNo, userID.(int64),
|
|
||||||
models.LaserCardActionGenerateVariants, "", models.LaserCardInstanceStatusRendered,
|
|
||||||
)
|
|
||||||
logger.Logger.Info("Laser card instance persisted",
|
|
||||||
zap.Int64("instance_id", instanceID),
|
|
||||||
zap.String("instance_no", renderedInst.InstanceNo),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
job := &GenerateJob{
|
job := &GenerateJob{
|
||||||
ID: jobID,
|
ID: jobID,
|
||||||
@ -248,6 +264,396 @@ func parseVariants(req generateReq) []variantConfig {
|
|||||||
return variants
|
return variants
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDifyBlocking 阻塞模式调 Dify 工作流
|
||||||
|
// 由 Dify 工作流内部完成抠图(可选)→ 5 variant 生成 → 合成, 返回完整结果
|
||||||
|
// 前端单次请求即可拿到 5 张图, 无需轮询
|
||||||
|
func (ctrl *LaserGenerateController) handleDifyBlocking(c *gin.Context, userID, starID int64, req generateReq) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// 构造 Dify 工作流 inputs
|
||||||
|
// 注意: Dify 平台限制, preset_codes / render_configs 必须以 JSON 字符串传入
|
||||||
|
presetCodesJSON, err := json.Marshal(req.PresetCodes)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalError(c, "序列化 preset_codes 失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderConfigsJSON, err := json.Marshal(req.RenderConfigs)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalError(c, "序列化 render_configs 失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// user_prompt 加权说明:
|
||||||
|
// - 当前前端 (useLaserDifyGenerate.resolveRenderConfigs) 已把 userPrompt 加权后写进
|
||||||
|
// 每个 render_config.bg_prompt(头部「主题(必须遵循,不可偏离)」前缀 + 末尾再重复一次),
|
||||||
|
// 同时把 user_prompt 字段传空,让后端不再二次拼接。
|
||||||
|
// - 此处保留 req.UserPrompt 兜底:若未来有其他调用方忘记前端加权,这里会以
|
||||||
|
// "末尾追加" 语义再追加一次 userPrompt 作为额外强化,避免 prompt 头部被污染。
|
||||||
|
// - 为避免与前端加权产生的两次重复叠加成三次,这里仅在 bg_prompt 不含 userPrompt 时才追加。
|
||||||
|
if req.UserPrompt != "" {
|
||||||
|
renderConfigsJSON = enrichRenderConfigsWithUserPrompt(req.RenderConfigs, req.UserPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs := map[string]interface{}{
|
||||||
|
"cutout_url": req.CutoutURL,
|
||||||
|
"preset_codes": string(presetCodesJSON),
|
||||||
|
"render_configs": string(renderConfigsJSON),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("Dify workflow invoke",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", starID),
|
||||||
|
zap.Int("preset_count", len(req.PresetCodes)),
|
||||||
|
zap.String("cutout_url_prefix", safePrefix(req.CutoutURL, 60)),
|
||||||
|
// [DEBUG-TEMP-2] 再次排查:确认前端加权逻辑生效 & Dify 节点是否更新
|
||||||
|
zap.String("debug_bg0_full", extractFirstBgPrompt(req.RenderConfigs)),
|
||||||
|
zap.String("debug_user_prompt_full", req.UserPrompt),
|
||||||
|
)
|
||||||
|
|
||||||
|
output, err := ctrl.difyClient.RunWorkflow(ctx, inputs, fmt.Sprintf("%d", userID))
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Dify workflow failed",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
response.InternalError(c, "Dify 工作流失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 Dify 输出 (variants / warnings 是 JSON 字符串, 来自 yml 结束节点)
|
||||||
|
variants := []map[string]interface{}{}
|
||||||
|
warnings := []string{}
|
||||||
|
cutoutURL := req.CutoutURL
|
||||||
|
|
||||||
|
if v, ok := output.Outputs["variants"]; ok && v != nil {
|
||||||
|
switch vt := v.(type) {
|
||||||
|
case string:
|
||||||
|
if err := json.Unmarshal([]byte(vt), &variants); err != nil {
|
||||||
|
logger.Logger.Warn("parse dify variants string failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
// 已被 dify_client 解析为数组
|
||||||
|
b, _ := json.Marshal(vt)
|
||||||
|
_ = json.Unmarshal(b, &variants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w, ok := output.Outputs["warnings"]; ok && w != nil {
|
||||||
|
if ws, ok := w.(string); ok {
|
||||||
|
_ = json.Unmarshal([]byte(ws), &warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cu, ok := output.Outputs["cutout_oss_key"].(string); ok && cu != "" {
|
||||||
|
cutoutURL = cu
|
||||||
|
} else if cu, ok := output.Outputs["cutout_url"].(string); ok && cu != "" {
|
||||||
|
cutoutURL = cu
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.Status == "failed" {
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"status": "failed",
|
||||||
|
"error": output.Error,
|
||||||
|
"warnings": warnings,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化: 统一走 persistGeneratedInstance + attachMaterialsSnapshot
|
||||||
|
templateCode := "default"
|
||||||
|
if len(req.PresetCodes) > 0 {
|
||||||
|
templateCode = req.PresetCodes[0]
|
||||||
|
}
|
||||||
|
var instanceID int64
|
||||||
|
var instanceNoResp string
|
||||||
|
instanceID, instanceNoResp = ctrl.persistGeneratedInstance(userID, starID, templateCode, "")
|
||||||
|
ctrl.attachMaterialsSnapshot(instanceID, instanceNoResp, userID, cutoutURL, "cutout", variants, "")
|
||||||
|
|
||||||
|
logger.Logger.Info("Dify workflow completed",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int("variant_count", len(variants)),
|
||||||
|
zap.Int("warnings", len(warnings)),
|
||||||
|
)
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"status": "succeeded",
|
||||||
|
"variants": variants,
|
||||||
|
"warnings": warnings,
|
||||||
|
"cutout_url": cutoutURL,
|
||||||
|
"instance_no": instanceNoResp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOpenAIDirect 阻塞模式调 OpenAI /v1/images/edits
|
||||||
|
//
|
||||||
|
// 流程(每路 variant 并发):
|
||||||
|
// 1. 调 openaiClient.EditImage(cutoutURL, bgPrompt) → 透明 PNG bytes
|
||||||
|
// 2. 直接落 OSS(走 RAM STS / AK 直连),key: laser-card/openai/{star}/{user}/{preset}_{rand}.png
|
||||||
|
// 3. 生成 1h 预签名 URL
|
||||||
|
// 4. 汇总 variants 数组,返回与 Dify 路径相同的契约
|
||||||
|
//
|
||||||
|
// 与 Dify 路径区别:
|
||||||
|
// - 不调 compositor 6 层合成(GPT 输出一张图,已经包含"假镭射"效果)
|
||||||
|
// - 不调 MiniMax(完全绕过文生图链路)
|
||||||
|
// - 失败时不重试,不降级,直接返回 error 写 warnings
|
||||||
|
func (ctrl *LaserGenerateController) handleOpenAIDirect(c *gin.Context, userID, starID int64, req generateReq) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if req.CutoutURL == "" {
|
||||||
|
response.BadRequest(c, "openai provider requires cutout_url (use image-to-image edit mode)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.RenderConfigs) == 0 {
|
||||||
|
response.BadRequest(c, "render_configs 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("OpenAI direct invoke",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", starID),
|
||||||
|
zap.Int("variant_count", len(req.RenderConfigs)),
|
||||||
|
zap.String("cutout_url_prefix", safePrefix(req.CutoutURL, 60)),
|
||||||
|
)
|
||||||
|
|
||||||
|
type variantResult struct {
|
||||||
|
Data map[string]interface{}
|
||||||
|
Err string
|
||||||
|
}
|
||||||
|
|
||||||
|
resultCh := make(chan variantResult, len(req.RenderConfigs))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, rc := range req.RenderConfigs {
|
||||||
|
if rc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, rc map[string]interface{}) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
presetID, _ := rc["preset_id"].(string)
|
||||||
|
bgPrompt, _ := rc["bg_prompt"].(string)
|
||||||
|
if presetID == "" {
|
||||||
|
presetID = fmt.Sprintf("variant_%d", idx)
|
||||||
|
}
|
||||||
|
if bgPrompt == "" {
|
||||||
|
resultCh <- variantResult{Err: fmt.Sprintf("%s: bg_prompt is empty", presetID)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: 调 OpenAI edit
|
||||||
|
pngBytes, err := ctrl.openaiClient.EditImage(ctx, req.CutoutURL, bgPrompt)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("OpenAI edit failed",
|
||||||
|
zap.String("preset", presetID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
resultCh <- variantResult{Err: fmt.Sprintf("%s: %v", presetID, err)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: 落 OSS
|
||||||
|
ossKey := fmt.Sprintf("laser-card/openai/%d/%d/%s_%s.png",
|
||||||
|
starID, userID, presetID, uuid.New().String()[:8])
|
||||||
|
if err := ctrl.ossHelper.PutObject(ossKey, bytes.NewReader(pngBytes), "image/png"); err != nil {
|
||||||
|
logger.Logger.Error("OSS upload failed",
|
||||||
|
zap.String("preset", presetID),
|
||||||
|
zap.String("oss_key", ossKey),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
resultCh <- variantResult{Err: fmt.Sprintf("%s: OSS upload failed: %v", presetID, err)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: 生成 1h 预签名 URL
|
||||||
|
signedURL, err := ctrl.ossHelper.SignGetURL(ossKey, 3600)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("SignGetURL failed",
|
||||||
|
zap.String("preset", presetID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
resultCh <- variantResult{Err: fmt.Sprintf("%s: sign URL failed: %v", presetID, err)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultCh <- variantResult{Data: map[string]interface{}{
|
||||||
|
"preset_id": presetID,
|
||||||
|
"oss_key": ossKey,
|
||||||
|
"signed_url": signedURL,
|
||||||
|
}}
|
||||||
|
|
||||||
|
logger.Logger.Info("OpenAI variant done",
|
||||||
|
zap.String("preset", presetID),
|
||||||
|
zap.String("oss_key", ossKey),
|
||||||
|
zap.Int("bytes", len(pngBytes)),
|
||||||
|
)
|
||||||
|
}(i, rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(resultCh)
|
||||||
|
|
||||||
|
variants := []map[string]interface{}{}
|
||||||
|
var warnings []string
|
||||||
|
for r := range resultCh {
|
||||||
|
if r.Err != "" {
|
||||||
|
warnings = append(warnings, r.Err)
|
||||||
|
} else if r.Data != nil {
|
||||||
|
variants = append(variants, r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(variants) == 0 {
|
||||||
|
response.InternalError(c, "OpenAI 生成全部失败: "+strings.Join(warnings, "; "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化(与 Dify 路径共用)
|
||||||
|
templateCode := "default"
|
||||||
|
if len(req.PresetCodes) > 0 {
|
||||||
|
templateCode = req.PresetCodes[0]
|
||||||
|
}
|
||||||
|
instanceID, instanceNo := ctrl.persistGeneratedInstance(userID, starID, templateCode, "")
|
||||||
|
// OpenAI 路径:源图是用户上传原图(没抠图),role 标记 "source_image" 区别于 Dify 的 "cutout"
|
||||||
|
ctrl.attachMaterialsSnapshot(instanceID, instanceNo, userID, req.CutoutURL, "source_image", variants, "")
|
||||||
|
|
||||||
|
logger.Logger.Info("OpenAI direct completed",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int("variant_count", len(variants)),
|
||||||
|
zap.Int("warning_count", len(warnings)),
|
||||||
|
)
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"status": "succeeded",
|
||||||
|
"variants": variants,
|
||||||
|
"warnings": warnings,
|
||||||
|
"cutout_url": req.CutoutURL,
|
||||||
|
"instance_no": instanceNo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRelayDifySingle 单张调试:Dify 增强 prompt → 中转站 edits → 落 OSS
|
||||||
|
//
|
||||||
|
// 流程:
|
||||||
|
//
|
||||||
|
// 1. 取 render_configs[0] 的 bg_prompt
|
||||||
|
// 2. 调 Dify laser_prompt_enhancer_v2 工作流增强 prompt(LLM 通过中转站)
|
||||||
|
// 3. 调中转站 /v1/images/edits(复用 openaiClient,原图 + 增强 prompt)
|
||||||
|
// 4. 落 OSS + 预签名 URL
|
||||||
|
// 5. 返回单个 variant(后续可扩展为 5 路并发 callDifyEnhance → bulk edits)
|
||||||
|
func (ctrl *LaserGenerateController) handleRelayDifySingle(c *gin.Context, userID, starID int64, req generateReq) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if len(req.RenderConfigs) == 0 {
|
||||||
|
response.BadRequest(c, "render_configs 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.CutoutURL == "" {
|
||||||
|
response.BadRequest(c, "cutout_url 不能为空(需原图 URL)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 取第一个 variant 的 bg_prompt
|
||||||
|
rc := req.RenderConfigs[0]
|
||||||
|
presetID, _ := rc["preset_id"].(string)
|
||||||
|
if presetID == "" {
|
||||||
|
presetID = "variant_0"
|
||||||
|
}
|
||||||
|
bgPrompt, _ := rc["bg_prompt"].(string)
|
||||||
|
if bgPrompt == "" {
|
||||||
|
response.BadRequest(c, "render_configs[0].bg_prompt 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("RelayDify single start",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", starID),
|
||||||
|
zap.String("preset_id", presetID),
|
||||||
|
zap.String("bg_prompt_prefix", safePrefix(bgPrompt, 60)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. 调 Dify 工作流增强 prompt
|
||||||
|
enhancedPrompt := bgPrompt // 默认 fallback
|
||||||
|
difyInputs := map[string]interface{}{
|
||||||
|
"bg_prompt": bgPrompt,
|
||||||
|
"user_prompt": req.UserPrompt,
|
||||||
|
}
|
||||||
|
difyOutput, err := ctrl.difyClient.RunWorkflow(ctx, difyInputs, fmt.Sprintf("%d", userID))
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Warn("Dify prompt enhancer failed, use raw prompt",
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if enhanced, ok := difyOutput.Outputs["enhanced_prompt"].(string); ok && enhanced != "" {
|
||||||
|
enhancedPrompt = enhanced
|
||||||
|
logger.Logger.Info("Dify prompt enhanced",
|
||||||
|
zap.Int("raw_len", len(bgPrompt)),
|
||||||
|
zap.Int("enhanced_len", len(enhancedPrompt)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Dify 返回了但字段格式不对,用原始 prompt 兜底
|
||||||
|
logger.Logger.Warn("Dify returned no enhanced_prompt field, use raw",
|
||||||
|
zap.Any("outputs", difyOutput.Outputs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 调中转站 /v1/images/edits
|
||||||
|
pngBytes, err := ctrl.openaiClient.EditImage(ctx, req.CutoutURL, enhancedPrompt)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Relay edits failed",
|
||||||
|
zap.String("preset", presetID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
response.InternalError(c, fmt.Sprintf("AI 生图失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(pngBytes) == 0 {
|
||||||
|
response.InternalError(c, "AI 生图返回为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 落 OSS(只取第一张)
|
||||||
|
ossKey := fmt.Sprintf("laser-card/relay-dify/%d/%d/%s_%s.png",
|
||||||
|
starID, userID, presetID, uuid.New().String()[:8])
|
||||||
|
if err := ctrl.ossHelper.PutObject(ossKey, bytes.NewReader(pngBytes), "image/png"); err != nil {
|
||||||
|
logger.Logger.Error("OSS upload failed",
|
||||||
|
zap.String("oss_key", ossKey),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
response.InternalError(c, fmt.Sprintf("OSS 上传失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signedURL, err := ctrl.ossHelper.SignGetURL(ossKey, 3600)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Warn("Sign URL failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 持久化 + 返回
|
||||||
|
templateCode := "default"
|
||||||
|
if len(req.PresetCodes) > 0 {
|
||||||
|
templateCode = req.PresetCodes[0]
|
||||||
|
}
|
||||||
|
instanceID, instanceNo := ctrl.persistGeneratedInstance(userID, starID, templateCode, "")
|
||||||
|
|
||||||
|
variants := []map[string]interface{}{{
|
||||||
|
"preset_id": presetID,
|
||||||
|
"oss_key": ossKey,
|
||||||
|
"signed_url": signedURL,
|
||||||
|
}}
|
||||||
|
ctrl.attachMaterialsSnapshot(instanceID, instanceNo, userID, req.CutoutURL, "source_image", variants, "")
|
||||||
|
|
||||||
|
logger.Logger.Info("RelayDify single succeeded",
|
||||||
|
zap.String("preset", presetID),
|
||||||
|
zap.Int("png_bytes", len(pngBytes)),
|
||||||
|
zap.String("oss_key", ossKey),
|
||||||
|
)
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"status": "succeeded",
|
||||||
|
"variants": variants,
|
||||||
|
"warnings": []string{},
|
||||||
|
"cutout_url": req.CutoutURL,
|
||||||
|
"instance_no": instanceNo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// runParallelGeneration 并行生成 5 个 variant:MiniMax(背景+装饰) → compositor 合成
|
// runParallelGeneration 并行生成 5 个 variant:MiniMax(背景+装饰) → compositor 合成
|
||||||
func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64, jobID, cutoutURL, userPrompt string, variants []variantConfig) {
|
func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64, jobID, cutoutURL, userPrompt string, variants []variantConfig) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@ -335,38 +741,49 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64,
|
|||||||
zap.String("cutout_url_prefix", safePrefix(cutoutURL, 50)),
|
zap.String("cutout_url_prefix", safePrefix(cutoutURL, 50)),
|
||||||
zap.String("overlay_url_prefix", safePrefix(overlayURL, 50)),
|
zap.String("overlay_url_prefix", safePrefix(overlayURL, 50)),
|
||||||
)
|
)
|
||||||
compReq := service.ComposeRequest{
|
compReq := compositor.ComposeRequest{
|
||||||
BackgroundURL: bgURL,
|
BackgroundURL: bgURL,
|
||||||
CutoutURL: cutoutURL,
|
CutoutURL: cutoutURL,
|
||||||
OverlayURL: overlayURL,
|
OverlayURL: overlayURL,
|
||||||
GratingConfig: vc.GratingConfig,
|
|
||||||
ExportWidth: 450,
|
ExportWidth: 450,
|
||||||
ExportHeight: 600,
|
ExportHeight: 600,
|
||||||
VariantIndex: idx,
|
VariantIndex: idx,
|
||||||
OutputOSSKey: ossKey,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
compResp, err := ctrl.compositorClient.Compose(ctx, compReq)
|
pngData, compErr := compositor.Compose(compReq)
|
||||||
if err != nil {
|
if compErr != nil {
|
||||||
logger.Logger.Error("Compositor failed",
|
logger.Logger.Error("Compositor failed",
|
||||||
zap.String("preset", vc.PresetID),
|
zap.String("preset", vc.PresetID),
|
||||||
zap.Error(err),
|
zap.Error(compErr),
|
||||||
)
|
)
|
||||||
resultCh <- variantResult{PresetID: vc.PresetID, Err: fmt.Sprintf("合成失败: %v", err)}
|
resultCh <- variantResult{PresetID: vc.PresetID, Err: fmt.Sprintf("合成失败: %v", compErr)}
|
||||||
done := int(atomic.AddInt32(&completed, 1))
|
done := int(atomic.AddInt32(&completed, 1))
|
||||||
updateProgress(done)
|
updateProgress(done)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compOSSKet := fmt.Sprintf("laser-card/minimax-local/%d/%d/%s", starID, userID, ossKey)
|
||||||
|
if err := ctrl.ossHelper.PutObject(compOSSKet, bytes.NewReader(pngData), "image/png"); err != nil {
|
||||||
|
logger.Logger.Error("Compositor OSS upload failed",
|
||||||
|
zap.String("preset", vc.PresetID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
resultCh <- variantResult{PresetID: vc.PresetID, Err: fmt.Sprintf("OSS上传失败: %v", err)}
|
||||||
|
done := int(atomic.AddInt32(&completed, 1))
|
||||||
|
updateProgress(done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
compSignedURL, signErr := ctrl.ossHelper.SignGetURL(compOSSKet, 3600)
|
||||||
|
if signErr != nil {
|
||||||
|
logger.Logger.Warn("Compositor sign URL failed", zap.Error(signErr))
|
||||||
|
}
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"preset_id": vc.PresetID,
|
"preset_id": vc.PresetID,
|
||||||
"oss_key": compResp.OSSKey,
|
"oss_key": compOSSKet,
|
||||||
"signed_url": compResp.SignedURL,
|
"signed_url": compSignedURL,
|
||||||
"width": compResp.Width,
|
"width": 450,
|
||||||
"height": compResp.Height,
|
"height": 600,
|
||||||
}
|
|
||||||
if compResp.Warning != "" {
|
|
||||||
data["warning"] = compResp.Warning
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resultCh <- variantResult{PresetID: vc.PresetID, Data: data}
|
resultCh <- variantResult{PresetID: vc.PresetID, Data: data}
|
||||||
@ -408,40 +825,9 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64,
|
|||||||
} else {
|
} else {
|
||||||
job.Status = "succeeded"
|
job.Status = "succeeded"
|
||||||
job.Progress = 1.0
|
job.Progress = 1.0
|
||||||
// 持久化:更新 materials_snapshot
|
// 持久化:更新 materials_snapshot(统一走 attachMaterialsSnapshot)
|
||||||
if ctrl.laserRepo != nil && job.InstanceID > 0 {
|
// MiniMax 异步路径:前端先抠图,job.CutoutURL 实际是抠过的人像
|
||||||
snapshot := make(models.MaterialsSnapshot, 0, len(job.Variants)+1)
|
ctrl.attachMaterialsSnapshot(job.InstanceID, job.InstanceNo, job.UserID, job.CutoutURL, "cutout", job.Variants, jobID)
|
||||||
// 用户原图/抠图记录
|
|
||||||
if job.CutoutURL != "" {
|
|
||||||
snapshot = append(snapshot, models.MaterialSnapshotItem{
|
|
||||||
Role: "cutout",
|
|
||||||
OssKey: job.CutoutURL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, v := range job.Variants {
|
|
||||||
ossKey, _ := v["oss_key"].(string)
|
|
||||||
presetID, _ := v["preset_id"].(string)
|
|
||||||
if ossKey != "" {
|
|
||||||
snapshot = append(snapshot, models.MaterialSnapshotItem{
|
|
||||||
Role: "composite",
|
|
||||||
OssKey: ossKey,
|
|
||||||
PresetID: presetID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ctrl.laserRepo.UpdateMaterialsSnapshot(job.InstanceID, snapshot); err != nil {
|
|
||||||
logger.Logger.Warn("Failed to update materials_snapshot",
|
|
||||||
zap.Int64("instance_id", job.InstanceID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ = ctrl.laserRepo.CreateOperationLogSimple(
|
|
||||||
job.InstanceID, job.InstanceNo, job.UserID,
|
|
||||||
models.LaserCardActionGenerateVariants,
|
|
||||||
models.LaserCardInstanceStatusRendered,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Logger.Info("Generate job completed",
|
logger.Logger.Info("Generate job completed",
|
||||||
@ -451,6 +837,19 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractFirstBgPrompt 调试辅助:返回 render_configs[0].bg_prompt
|
||||||
|
func extractFirstBgPrompt(renderConfigs []map[string]interface{}) string {
|
||||||
|
for _, rc := range renderConfigs {
|
||||||
|
if rc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bg, ok := rc["bg_prompt"].(string); ok {
|
||||||
|
return bg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func marshalToString(v interface{}) string {
|
func marshalToString(v interface{}) string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return ""
|
return ""
|
||||||
@ -462,9 +861,159 @@ func marshalToString(v interface{}) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrichRenderConfigsWithUserPrompt 把 userPrompt 追加到每个 render_config.bg_prompt 末尾。
|
||||||
|
//
|
||||||
|
// 约定:前端 (useLaserDifyGenerate.resolveRenderConfigs) 已经把 userPrompt 加权后写进
|
||||||
|
// bg_prompt 头部(「主题(必须遵循,不可偏离)」前缀 + 末尾再重复一次);此处仅在 bg_prompt
|
||||||
|
// 不包含 userPrompt 时追加,避免与前端加权叠加成 3 次重复。
|
||||||
|
//
|
||||||
|
// 若 bg_prompt 为空,直接以「主题(必须遵循,不可偏离)」开头建立主题锚点。
|
||||||
|
//
|
||||||
|
// 返回:序列化后的 JSON 字节,可直接放入 Dify inputs.render_configs。
|
||||||
|
func enrichRenderConfigsWithUserPrompt(renderConfigs []map[string]interface{}, userPrompt string) []byte {
|
||||||
|
if userPrompt == "" {
|
||||||
|
b, _ := json.Marshal(renderConfigs)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
enriched := make([]map[string]interface{}, 0, len(renderConfigs))
|
||||||
|
for _, rc := range renderConfigs {
|
||||||
|
enrichedOne := make(map[string]interface{}, len(rc)+1)
|
||||||
|
for k, v := range rc {
|
||||||
|
enrichedOne[k] = v
|
||||||
|
}
|
||||||
|
bg, _ := enrichedOne["bg_prompt"].(string)
|
||||||
|
switch {
|
||||||
|
case bg == "":
|
||||||
|
enrichedOne["bg_prompt"] = "主题(必须遵循,不可偏离): " + userPrompt
|
||||||
|
case strings.Contains(bg, userPrompt):
|
||||||
|
// 前端已加权,跳过避免重复
|
||||||
|
default:
|
||||||
|
enrichedOne["bg_prompt"] = bg + ". 主题强调: " + userPrompt
|
||||||
|
}
|
||||||
|
enriched = append(enriched, enrichedOne)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(enriched)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func safePrefix(s string, n int) string {
|
func safePrefix(s string, n int) string {
|
||||||
if len(s) <= n {
|
if len(s) <= n {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:n] + "..."
|
return s[:n] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// persistGeneratedInstance 创建 status=rendered 的 laser_card_instance + 写一条创建操作日志
|
||||||
|
//
|
||||||
|
// Dify 阻塞模式与 MiniMax 异步模式共用此方法。
|
||||||
|
// 即使 laserRepo 为 nil 或创建失败也不中断:返回 (0, ""),调用方可决定降级策略。
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - instanceID > 0 表示创建成功;调用方应继续调用 attachMaterialsSnapshot
|
||||||
|
// - instanceID == 0 表示创建失败(laserRepo 为 nil / DB 写入失败)
|
||||||
|
func (ctrl *LaserGenerateController) persistGeneratedInstance(userID, starID int64, templateCode, jobID string) (int64, string) {
|
||||||
|
if ctrl.laserRepo == nil {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedInst := &models.LaserCardInstance{
|
||||||
|
InstanceNo: "", // BeforeCreate 自动生成
|
||||||
|
InstanceUlid: "", // BeforeCreate 自动生成
|
||||||
|
TemplateID: 0, // 种子数据 id=1-5,按 code 查找
|
||||||
|
TemplateCode: templateCode,
|
||||||
|
TemplateVersion: 1,
|
||||||
|
OwnerUserID: userID,
|
||||||
|
StarID: starID,
|
||||||
|
Status: models.LaserCardInstanceStatusRendered,
|
||||||
|
MaterialsSnapshot: models.MaterialsSnapshot{},
|
||||||
|
}
|
||||||
|
// 尝试按 template_code 查到真实 template_id
|
||||||
|
if tpl, err := ctrl.laserRepo.FindTemplateByCode(templateCode); err == nil {
|
||||||
|
renderedInst.TemplateID = tpl.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.laserRepo.CreateInstance(renderedInst); err != nil {
|
||||||
|
logger.Logger.Warn("Failed to persist laser_card_instance on create",
|
||||||
|
zap.String("job_id", jobID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceID := renderedInst.ID
|
||||||
|
instanceNo := renderedInst.InstanceNo
|
||||||
|
|
||||||
|
logger.Logger.Info("Laser card instance persisted",
|
||||||
|
zap.Int64("instance_id", instanceID),
|
||||||
|
zap.String("instance_no", instanceNo),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 写创建操作日志(statusBefore="" → statusAfter="rendered")
|
||||||
|
_ = ctrl.laserRepo.CreateOperationLogSimple(
|
||||||
|
instanceID, instanceNo, userID,
|
||||||
|
models.LaserCardActionGenerateVariants, "", models.LaserCardInstanceStatusRendered,
|
||||||
|
)
|
||||||
|
|
||||||
|
return instanceID, instanceNo
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachMaterialsSnapshot 把"源图" + variants 装配成 MaterialsSnapshot 并写库,
|
||||||
|
// 写一条 snapshot 更新操作日志。
|
||||||
|
//
|
||||||
|
// sourceRole:
|
||||||
|
// - "cutout" : Dify / MiniMax 路径,源图是抠过的人像
|
||||||
|
// - "source_image" : OpenAI 路径,源图是用户上传原图(没经过 segment)
|
||||||
|
//
|
||||||
|
// instanceID <= 0 时直接返回(persistGeneratedInstance 创建失败,跳过 snapshot)。
|
||||||
|
// snapshot 写失败仅 warn,不影响调用方其他逻辑。
|
||||||
|
func (ctrl *LaserGenerateController) attachMaterialsSnapshot(instanceID int64, instanceNo string, userID int64, sourceURL, sourceRole string, variants []map[string]interface{}, jobID string) {
|
||||||
|
if ctrl.laserRepo == nil || instanceID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := buildMaterialsSnapshot(sourceURL, sourceRole, variants)
|
||||||
|
|
||||||
|
if err := ctrl.laserRepo.UpdateMaterialsSnapshot(instanceID, snapshot); err != nil {
|
||||||
|
logger.Logger.Warn("Failed to update materials_snapshot",
|
||||||
|
zap.String("job_id", jobID),
|
||||||
|
zap.Int64("instance_id", instanceID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写 snapshot 更新操作日志(statusBefore="rendered" → statusAfter="")
|
||||||
|
_ = ctrl.laserRepo.CreateOperationLogSimple(
|
||||||
|
instanceID, instanceNo, userID,
|
||||||
|
models.LaserCardActionGenerateVariants,
|
||||||
|
models.LaserCardInstanceStatusRendered, "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMaterialsSnapshot 把"源图" + variants 装配成 MaterialsSnapshot JSON。
|
||||||
|
// 顺序:源图 先,variants 后;过滤掉 oss_key 为空的项。
|
||||||
|
//
|
||||||
|
// sourceRole 区分源图语义:
|
||||||
|
// - "cutout" : Dify / MiniMax 路径,源图是抠过的人像(compositor 6 层合成用)
|
||||||
|
// - "source_image" : OpenAI 路径,源图是用户上传原图(GPT-image 自己理解,不再扣图)
|
||||||
|
func buildMaterialsSnapshot(sourceURL, sourceRole string, variants []map[string]interface{}) models.MaterialsSnapshot {
|
||||||
|
snapshot := make(models.MaterialsSnapshot, 0, len(variants)+1)
|
||||||
|
if sourceURL != "" {
|
||||||
|
snapshot = append(snapshot, models.MaterialSnapshotItem{
|
||||||
|
Role: sourceRole,
|
||||||
|
OssKey: sourceURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, v := range variants {
|
||||||
|
ossKey, _ := v["oss_key"].(string)
|
||||||
|
presetID, _ := v["preset_id"].(string)
|
||||||
|
if ossKey != "" {
|
||||||
|
snapshot = append(snapshot, models.MaterialSnapshotItem{
|
||||||
|
Role: "composite",
|
||||||
|
OssKey: ossKey,
|
||||||
|
PresetID: presetID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/topfans/backend/gateway/config"
|
"github.com/topfans/backend/gateway/config"
|
||||||
"github.com/topfans/backend/gateway/controller"
|
"github.com/topfans/backend/gateway/controller"
|
||||||
"github.com/topfans/backend/gateway/middleware"
|
"github.com/topfans/backend/gateway/middleware"
|
||||||
|
"github.com/topfans/backend/gateway/service"
|
||||||
"github.com/topfans/backend/gateway/socket"
|
"github.com/topfans/backend/gateway/socket"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -104,6 +105,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
|||||||
segmentCtrl := controller.NewSegmentController()
|
segmentCtrl := controller.NewSegmentController()
|
||||||
|
|
||||||
laserGenCtrl := controller.NewLaserGenerateController(config.Load())
|
laserGenCtrl := controller.NewLaserGenerateController(config.Load())
|
||||||
|
composeCtrl := controller.NewComposeController(service.NewOssHelper(config.Load().OSS))
|
||||||
aiChatCtrl, err := controller.NewAIChatController(aiChatClient)
|
aiChatCtrl, err := controller.NewAIChatController(aiChatClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -244,6 +246,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
|||||||
{
|
{
|
||||||
laser.POST("/generate", laserGenCtrl.CreateGenerateJob)
|
laser.POST("/generate", laserGenCtrl.CreateGenerateJob)
|
||||||
laser.GET("/generate/:id", laserGenCtrl.GetGenerateJob)
|
laser.GET("/generate/:id", laserGenCtrl.GetGenerateJob)
|
||||||
|
laser.POST("/compose", composeCtrl.ComposeSingle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 资产相关路由(需要认证)
|
// 资产相关路由(需要认证)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ func NewDifyClient(baseURL, apiKey string) *DifyClient {
|
|||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
HTTPClient: &http.Client{
|
HTTPClient: &http.Client{
|
||||||
Timeout: 120 * time.Second, // Dify 工作流最长等待 120s
|
Timeout: 600 * time.Second, // Dify 工作流(MiniMax 文生图 + 合成), 最长约 300s, 留 300s 余量
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
321
backend/gateway/service/openai_client.go
Normal file
321
backend/gateway/service/openai_client.go
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAIClient OpenAI Images API 客户端
|
||||||
|
//
|
||||||
|
// 当前仅实现 /v1/images/edits 端点(图生图,镭射卡场景):
|
||||||
|
// - 输入:远程图片 URL(由本服务先下载) + 风格 prompt
|
||||||
|
// - 输出:PNG 字节流(透明背景,直接可上传 OSS)
|
||||||
|
//
|
||||||
|
// 不接 /v1/images/generations(文生图),不接 variations(已弃用)。
|
||||||
|
// 失败时直接 error,无任何 fallback,由 controller 决定如何处理。
|
||||||
|
type OpenAIClient struct {
|
||||||
|
BaseURL string // 默认 https://api.openai.com/v1
|
||||||
|
APIKey string
|
||||||
|
Model string // 默认 gpt-image-1.5
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenAIClient 创建 OpenAI 客户端
|
||||||
|
func NewOpenAIClient(baseURL, apiKey, model string) *OpenAIClient {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = "gpt-image-1.5"
|
||||||
|
}
|
||||||
|
return &OpenAIClient{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
APIKey: apiKey,
|
||||||
|
Model: model,
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Timeout: 360 * time.Second, // gpt-image-2 通过中转站时可能达 5min+, 设 6min
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIEditResponse /v1/images/edits 响应结构
|
||||||
|
type OpenAIEditResponse struct {
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Data []struct {
|
||||||
|
B64JSON string `json:"b64_json,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
RevisedPrompt string `json:"revised_prompt,omitempty"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditImage 调用 OpenAI /v1/images/edits 端点
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - imageURL: 远程图片 URL(必须是 GET 可访问的图片,OpenAI 服务端会拉取)
|
||||||
|
// - prompt: 风格描述(<= 32000 字符)
|
||||||
|
//
|
||||||
|
// 返回:解码后的 PNG 字节流;失败返回 error
|
||||||
|
func (c *OpenAIClient) EditImage(ctx context.Context, imageURL, prompt string) ([]byte, error) {
|
||||||
|
if c.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("OpenAI API key not configured (set OPENAI_API_KEY)")
|
||||||
|
}
|
||||||
|
if imageURL == "" {
|
||||||
|
return nil, fmt.Errorf("imageURL is required for edit endpoint")
|
||||||
|
}
|
||||||
|
if prompt == "" {
|
||||||
|
return nil, fmt.Errorf("prompt is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基底镭射卡模板 + 拼接用户提示词
|
||||||
|
// 用户提示词建议传入英文,中文 GPT-image 理解效果略差
|
||||||
|
prompt = `Transform the input image into a premium holographic visual artwork.
|
||||||
|
|
||||||
|
Preserve the original subject identity, pose, and composition.
|
||||||
|
|
||||||
|
This is a high-end artistic re-imagining of the photograph, not a card, not a poster.
|
||||||
|
|
||||||
|
Randomly apply combinations of:
|
||||||
|
holographic light refraction, prismatic color grading, iridescent reflections,
|
||||||
|
laser light dispersion, metallic sheen overlays, glass-like distortion,
|
||||||
|
aurora color bleeding, spectral glow enhancement, cinematic post-processing,
|
||||||
|
abstract particle integration.
|
||||||
|
|
||||||
|
Random holographic styles:
|
||||||
|
oil-slick sheen, prism refraction, aurora diffusion, laser scattering,
|
||||||
|
liquid metal reflection, fractured spectrum, holographic bloom,
|
||||||
|
rainbow diffraction, crystal refraction, spectral distortion.
|
||||||
|
|
||||||
|
Visual direction may vary between:
|
||||||
|
luxury editorial photography, experimental fine art, cinematic portrait enhancement,
|
||||||
|
museum-grade print, surreal holographic art, avant-garde visual experiment.
|
||||||
|
|
||||||
|
Background may subtly transform into abstract light fields, cosmic gradients,
|
||||||
|
or studio-like luxury atmospheres.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
Do not add frames, borders, cards, labels, or typography.
|
||||||
|
Do not convert into trading card or poster layout.
|
||||||
|
Do not alter subject identity.
|
||||||
|
|
||||||
|
Result should feel like a luxury holographic fine art photograph.
|
||||||
|
Ultra detailed, high-end commercial imaging quality.
|
||||||
|
|
||||||
|
User direction: ` + prompt
|
||||||
|
|
||||||
|
// 1. 下载远程图片到 []byte
|
||||||
|
imgData, err := downloadRemoteImage(ctx, imageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 构造 multipart/form-data body
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
// image[]: PNG 文件
|
||||||
|
// ⚠️ 不能用 CreateFormFile —— 它不设置 Content-Type, multipart 默认会给二进制 part
|
||||||
|
// 加 application/octet-stream, OpenAI 后端只接受 image/{jpeg,png,webp},会直接 400
|
||||||
|
// 改用 CreatePart 手动探测真实 MIME 并显式设置
|
||||||
|
contentType := detectImageContentType(imgData, "input.png")
|
||||||
|
h := make(textproto.MIMEHeader)
|
||||||
|
h.Set("Content-Disposition", `form-data; name="image[]"; filename="input.png"`)
|
||||||
|
h.Set("Content-Type", contentType)
|
||||||
|
part, err := writer.CreatePart(h)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create form file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(imgData); err != nil {
|
||||||
|
return nil, fmt.Errorf("write image data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单字段
|
||||||
|
fields := buildEditFields(c.Model, prompt)
|
||||||
|
for k, v := range fields {
|
||||||
|
if err := writer.WriteField(k, v); err != nil {
|
||||||
|
return nil, fmt.Errorf("write field %s: %w", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("close multipart writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 发送请求
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
||||||
|
c.BaseURL+"/images/edits", body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openai request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, safePrefixBytes(respBody, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 解析响应
|
||||||
|
var edResp OpenAIEditResponse
|
||||||
|
if err := json.Unmarshal(respBody, &edResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse response: %w, body=%s", err, safePrefixBytes(respBody, 500))
|
||||||
|
}
|
||||||
|
if edResp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("openai error: %s - %s", edResp.Error.Code, edResp.Error.Message)
|
||||||
|
}
|
||||||
|
if len(edResp.Data) == 0 {
|
||||||
|
return nil, fmt.Errorf("openai returned no data array")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 解析图片:优先 b64_json,fallback 到 url
|
||||||
|
pngBytes, source, err := decodeImageFromResponse(ctx, &edResp.Data[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("OpenAI edit image succeeded",
|
||||||
|
zap.String("model", c.Model),
|
||||||
|
zap.String("source", source),
|
||||||
|
zap.Int("bytes", len(pngBytes)),
|
||||||
|
zap.String("revised_prompt", safePrefixBytes([]byte(edResp.Data[0].RevisedPrompt), 80)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return pngBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadRemoteImage 下载远程图片到 []byte
|
||||||
|
// 失败时返回 error,无 fallback
|
||||||
|
func downloadRemoteImage(ctx context.Context, imageURL string) ([]byte, error) {
|
||||||
|
cli := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("download HTTP %d (url prefix: %s)", resp.StatusCode, safePrefixStr(imageURL, 60))
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safePrefixBytes 截断字节用于日志(避免巨大 body 撑爆日志)
|
||||||
|
func safePrefixBytes(b []byte, n int) string {
|
||||||
|
if len(b) <= n {
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
return string(b[:n]) + "...(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// safePrefixStr 截断字符串
|
||||||
|
func safePrefixStr(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEditFields 根据 model 名前缀构造 /v1/images/edits 的 multipart 字段集。
|
||||||
|
//
|
||||||
|
// gpt-image-*: OpenAI 新版,支持 transparent 背景 + 1024x1536 竖版 + 高级 quality
|
||||||
|
// 其他(包括 dall-e-* / image-*): 旧版 / 中转站,只接受基础字段,size 限定 square
|
||||||
|
func buildEditFields(model, prompt string) map[string]string {
|
||||||
|
fields := map[string]string{
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"n": "1",
|
||||||
|
"size": "1024x1536",
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeImageFromResponse 从 OpenAI 响应里解出 PNG 字节。
|
||||||
|
//
|
||||||
|
// 优先 b64_json(OpenAI 官方 / 中转站都常用),fallback 到 url(要再发一次 GET)。
|
||||||
|
// 返回 (bytes, source, err) — source 写日志用,标识数据来自 b64_json 还是 url 下载。
|
||||||
|
func decodeImageFromResponse(ctx context.Context, item *struct {
|
||||||
|
B64JSON string `json:"b64_json,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
RevisedPrompt string `json:"revised_prompt,omitempty"`
|
||||||
|
}) ([]byte, string, error) {
|
||||||
|
if item.B64JSON != "" {
|
||||||
|
pngBytes, err := base64.StdEncoding.DecodeString(item.B64JSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("base64 decode: %w", err)
|
||||||
|
}
|
||||||
|
return pngBytes, "b64_json", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.URL != "" {
|
||||||
|
// 走 downloadRemoteImage 拉 URL 字节(dall-e-2 旧版经常返这种)
|
||||||
|
// 但 image URL 通常 1h 过期,务必立即下载
|
||||||
|
imgData, err := downloadRemoteImage(ctx, item.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("download openai output url: %w", err)
|
||||||
|
}
|
||||||
|
return imgData, "url", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("openai response item has neither b64_json nor url")
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectImageContentType 探测图片真实 MIME,供 multipart Content-Type 使用。
|
||||||
|
//
|
||||||
|
// 策略:
|
||||||
|
// 1. 先按 magic bytes(http.DetectContentType) —— 不依赖文件名/扩展名,准
|
||||||
|
// 2. 检测失败(空文件/未知格式)时, fallback 到文件名扩展名(应该是 input.png → image/png)
|
||||||
|
// 3. 兜底 application/octet-stream
|
||||||
|
//
|
||||||
|
// 返回值限定 OpenAI 接受的 image/{jpeg,png,webp} 集合,其他格式也走 octet-stream 让 OpenAI 直接拒绝
|
||||||
|
// (而不是上传个错的格式,后端报个更离谱的错)
|
||||||
|
func detectImageContentType(data []byte, filename string) string {
|
||||||
|
detected := http.DetectContentType(data) // 返回 "image/png" 等
|
||||||
|
switch detected {
|
||||||
|
case "image/jpeg", "image/png", "image/webp":
|
||||||
|
return detected
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测不到或不在 OpenAI 白名单 → 用文件名扩展名兜底
|
||||||
|
if ext := strings.ToLower(filepath.Ext(filename)); ext != "" {
|
||||||
|
if mt := mime.TypeByExtension(ext); mt != "" {
|
||||||
|
// mime.TypeByExtension 返回值如 "image/png; charset=utf-8",需要去掉 charset
|
||||||
|
if i := strings.Index(mt, ";"); i > 0 {
|
||||||
|
mt = strings.TrimSpace(mt[:i])
|
||||||
|
}
|
||||||
|
switch mt {
|
||||||
|
case "image/jpeg", "image/png", "image/webp":
|
||||||
|
return mt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底 —— OpenAI 会以 "unsupported mimetype" 拒绝,但请求能正常发出去便于诊断
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
@ -24,13 +24,19 @@ const (
|
|||||||
func MaxSegmentImageBytes() int { return maxSegmentImageBytes }
|
func MaxSegmentImageBytes() int { return maxSegmentImageBytes }
|
||||||
|
|
||||||
// SegmentPortraitResult 人像抠图结果
|
// SegmentPortraitResult 人像抠图结果
|
||||||
|
//
|
||||||
|
// 字段语义:
|
||||||
|
// - CutoutOssKey/URLSigned: 抠过的透明人像 PNG(给 compositor 6 层合成人像层用)
|
||||||
|
// - OriginalOssKey/URLSigned: 用户上传的原图(给 OpenAI 直接喂原图用,不再二次抠图)
|
||||||
type SegmentPortraitResult struct {
|
type SegmentPortraitResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
ErrorCode string `json:"error_code,omitempty"`
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
CutoutOssKey string `json:"cutout_oss_key,omitempty"`
|
CutoutOssKey string `json:"cutout_oss_key,omitempty"`
|
||||||
CutoutURLSigned string `json:"cutout_url_signed,omitempty"`
|
CutoutURLSigned string `json:"cutout_url_signed,omitempty"`
|
||||||
Provider string `json:"provider,omitempty"`
|
OriginalOssKey string `json:"original_oss_key,omitempty"`
|
||||||
|
OriginalURLSigned string `json:"original_url_signed,omitempty"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SegmentService 服务端人像抠图(imageseg / IVPD / 自部署 HTTP)
|
// SegmentService 服务端人像抠图(imageseg / IVPD / 自部署 HTTP)
|
||||||
@ -136,10 +142,15 @@ func (s *SegmentService) doPortrait(ctx context.Context, starID, userID int64, f
|
|||||||
cutout, err := s.inferCutout(ctx, starID, userID, fileID, ext, imageData, contentType)
|
cutout, err := s.inferCutout(ctx, starID, userID, fileID, ext, imageData, contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Logger.Warn("segment infer failed", zap.Error(err))
|
logger.Logger.Warn("segment infer failed", zap.Error(err))
|
||||||
|
// 即使抠图失败,原图已上传到 OSS,返回原图 URL 供 OpenAI 路径使用
|
||||||
|
inKey := BuildSegmentTempInputKey(starID, userID, fileID, ext)
|
||||||
|
inSigned, _ := s.oss.SignGetURL(inKey, 3600)
|
||||||
return &SegmentPortraitResult{
|
return &SegmentPortraitResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorCode: SegmentErrorCodeFailed,
|
ErrorCode: SegmentErrorCodeFailed,
|
||||||
Message: err.Error(),
|
Message: "抠图失败: " + err.Error(),
|
||||||
|
OriginalOssKey: inKey,
|
||||||
|
OriginalURLSigned: inSigned,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,24 +166,30 @@ func (s *SegmentService) doPortrait(ctx context.Context, starID, userID int64, f
|
|||||||
outSigned, err := s.oss.SignGetURL(outKey, 3600)
|
outSigned, err := s.oss.SignGetURL(outKey, 3600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SegmentPortraitResult{
|
return &SegmentPortraitResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
CutoutOssKey: outKey,
|
CutoutOssKey: outKey,
|
||||||
Provider: cutout.Provider,
|
OriginalOssKey: cutout.InKey,
|
||||||
Message: "抠图成功但签名 URL 生成失败",
|
OriginalURLSigned: cutout.InURLSigned,
|
||||||
|
Provider: cutout.Provider,
|
||||||
|
Message: "抠图成功但签名 URL 生成失败",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SegmentPortraitResult{
|
return &SegmentPortraitResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
CutoutOssKey: outKey,
|
CutoutOssKey: outKey,
|
||||||
CutoutURLSigned: outSigned,
|
CutoutURLSigned: outSigned,
|
||||||
Provider: cutout.Provider,
|
OriginalOssKey: cutout.InKey,
|
||||||
|
OriginalURLSigned: cutout.InURLSigned,
|
||||||
|
Provider: cutout.Provider,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type cutoutInferResult struct {
|
type cutoutInferResult struct {
|
||||||
Bytes []byte
|
Bytes []byte
|
||||||
Provider string
|
Provider string
|
||||||
|
InKey string // 额外:原图 OSS key(供 OpenAI 模式直接拿原图,不再二次抠图)
|
||||||
|
InURLSigned string // 额外:原图签名 URL
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64, fileID, ext string, imageData []byte, contentType string) (*cutoutInferResult, error) {
|
func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64, fileID, ext string, imageData []byte, contentType string) (*cutoutInferResult, error) {
|
||||||
@ -203,7 +220,7 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &cutoutInferResult{Bytes: raw, Provider: "imageseg"}, nil
|
return &cutoutInferResult{Bytes: raw, Provider: "imageseg", InKey: inKey, InURLSigned: inSigned}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tryIVPD := func() (*cutoutInferResult, error) {
|
tryIVPD := func() (*cutoutInferResult, error) {
|
||||||
@ -218,7 +235,7 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &cutoutInferResult{Bytes: raw, Provider: "ivpd"}, nil
|
return &cutoutInferResult{Bytes: raw, Provider: "ivpd", InKey: inKey, InURLSigned: inSigned}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tryHTTP := func() (*cutoutInferResult, error) {
|
tryHTTP := func() (*cutoutInferResult, error) {
|
||||||
@ -226,7 +243,8 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &cutoutInferResult{Bytes: raw, Provider: "http"}, nil
|
// tryHTTP 不走 OSS 中转,但原图已经被 line 181 上传,这里也带上
|
||||||
|
return &cutoutInferResult{Bytes: raw, Provider: "http", InKey: inKey, InURLSigned: inSigned}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|||||||
@ -7,7 +7,6 @@ use (
|
|||||||
./services/aiChatService
|
./services/aiChatService
|
||||||
./services/assetService
|
./services/assetService
|
||||||
./services/galleryService
|
./services/galleryService
|
||||||
./services/laserCompositor
|
|
||||||
./services/socialService
|
./services/socialService
|
||||||
./services/statisticService
|
./services/statisticService
|
||||||
./services/taskService
|
./services/taskService
|
||||||
|
|||||||
@ -23,3 +23,16 @@ OSS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD
|
|||||||
OSS_AVATAR_DIR=avatar/
|
OSS_AVATAR_DIR=avatar/
|
||||||
OSS_ASSET_DIR=asset/
|
OSS_ASSET_DIR=asset/
|
||||||
OSS_TOKEN_EXPIRE_TIME=3600
|
OSS_TOKEN_EXPIRE_TIME=3600
|
||||||
|
|
||||||
|
# ==================== 镭射卡生成器 ====================
|
||||||
|
# minimax (默认) | dify | openai
|
||||||
|
LASER_GEN_PROVIDER=minimax
|
||||||
|
|
||||||
|
# ==================== OpenAI Images API (LASER_GEN_PROVIDER=openai 时使用) ====================
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-image-1.5
|
||||||
|
|
||||||
|
# ==================== Dify Workflow (laser_card_variants_v1) ====================
|
||||||
|
DIFY_API_BASE=https://api.dify.ai/v1
|
||||||
|
DIFY_API_KEY=
|
||||||
|
|||||||
52
docker/.env.local.dev
Normal file
52
docker/.env.local.dev
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# ===================================================================
|
||||||
|
# TopFans Docker - 本机开发环境真实凭据(untracked,不进 git)
|
||||||
|
# 由 docker-compose --env-file .env.local --env-file .env.local.dev 叠加读入
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# 宿主机手起的 topfans-postgres
|
||||||
|
DB_USER=haihuizhu
|
||||||
|
DB_PASSWORD=admin
|
||||||
|
DB_NAME=top-fans
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_HOST=host.docker.internal
|
||||||
|
|
||||||
|
# 宿主机手起的 topfans-redis(重建后通过容器名 topfans-redis:6379 直连)
|
||||||
|
REDIS_HOST=topfans-redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=123456
|
||||||
|
|
||||||
|
# ==================== 镭射卡 OpenAI 中转站路径(覆盖 .env.local 的默认值)====================
|
||||||
|
# 前端 VITE_API_BASE_URL=http://localhost:18090 → 打到 docker gateway (18090)
|
||||||
|
# 所以 LASER_GEN_PROVIDER 必须在这里改,docker-compose.override.yml 第 92 行
|
||||||
|
# LASER_GEN_PROVIDER: ${LASER_GEN_PROVIDER:-minimax}
|
||||||
|
# 会把这里注入到容器,覆盖 .env.local 的 minimax 默认值
|
||||||
|
LASER_GEN_PROVIDER=openai
|
||||||
|
# 中转站 BaseURL —— XBCL 必须含 /v1 后缀(代码会拼成 https://xbcl.link/v1/images/edits)
|
||||||
|
# 直连 OpenAI 官方会被墙,这里走 XBCL 中转
|
||||||
|
# XBCL 的 chat / image / 其他端点都挂在 /v1/... 下,这是 OpenAI 兼容中转的标准路径
|
||||||
|
OPENAI_BASE_URL=https://xbcl.link/v1
|
||||||
|
# 中转站 model:XBCL /v1/models 实际暴露的 image 类模型是 gpt-image-1 / 1.5 / 2
|
||||||
|
# openai_client.go 的 buildEditFields() 根据 model 名前缀自动选参数集
|
||||||
|
# - gpt-image-* → 完整参数(transparent + 1024x1536 竖版)
|
||||||
|
# - 其他 → 基础参数(1024x1024 square,无 transparent,保守路径)
|
||||||
|
OPENAI_MODEL=gpt-image-2
|
||||||
|
# ⚠️ 中转站 API key —— ⚠️ 务必先在 https://xbcl.link 撤销旧 key 再填新的
|
||||||
|
# 因为之前 OpenAI 官方 key 已在对话历史里泄漏过,直接换中转站独立 key
|
||||||
|
# 撤销旧 key → 生成新 key → 把值贴到下面这一行
|
||||||
|
OPENAI_API_KEY=sk-b1f01c1ebc177e8fbd8e19ca3edeb542b521c39039977ef2974ce06c5d4cc18d
|
||||||
|
|
||||||
|
# ==================== Dify 配置保留(暂未切回 dify,留着方便回滚)====================
|
||||||
|
# gateway 容器的 DIFY_API_BASE 默认是 https://api.dify.ai/v1(生产 SaaS)
|
||||||
|
# 本机要走自起的 Dify(project=dify,nginx 暴露在 host:80 → /v1 路由到 api:5001)
|
||||||
|
#
|
||||||
|
# ⚠️ 不能用 host.docker.internal!Docker Desktop 的 com.docker.backend.exe
|
||||||
|
# 抢占了 host 的 0.0.0.0:80,host.docker.internal (192.168.65.254) 走不通。
|
||||||
|
# 用 host 真实 IP 172.23.0.1 直连 host:80(Dify nginx bind 在这)
|
||||||
|
DIFY_API_BASE=http://172.23.0.1/v1
|
||||||
|
# 从 Dify 数据库里 laser_card_variants_v1 这个 app 的 api_tokens 表里取出来的
|
||||||
|
DIFY_API_KEY=app-Ibs7reARanyuYGZ7zrLyiM6e
|
||||||
|
|
||||||
|
# ==================== JWT_SECRET ====================
|
||||||
|
# 本机用生产同款 secret,让生产签发的 token 在本机 gateway 也能验签通过
|
||||||
|
# ⚠️ 本地开发用,绝对不要把生产 secret 提交到 git
|
||||||
|
JWT_SECRET=topfans-secret-key-please-change-in-production
|
||||||
@ -22,7 +22,7 @@ OSS_ASSET_DIR=asset/
|
|||||||
OSS_TOKEN_EXPIRE_TIME=3600
|
OSS_TOKEN_EXPIRE_TIME=3600
|
||||||
|
|
||||||
# ==================== MiniMax API Configuration ====================
|
# ==================== MiniMax API Configuration ====================
|
||||||
MINIMAX_API_KEY=sk-cp-Fffv8Bg8zeFD929_KUAZq9EKet64Nkxgu7t1ibZEngqmyPKaOOa7U8U_gtg3VICfUQyGPn8c5XR4hxmWzjKC4wO6DxKh5ipN36Yv5jsFzZWMEPh6NKV2qAE
|
MINIMAX_API_KEY=sk-api-oezuuNMr5iwPdlJ1JgTJTSzhMhGtaUR5Odjjg0ZqVQ7MoMIqLuE_ginMWRkNiAiDgMY6MvTVkYCWSQ8SK1-LuldrFmohCHxCgZIbxsFYr9zxA8z08Eb8nbo
|
||||||
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation
|
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation
|
||||||
|
|
||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
@ -31,11 +31,23 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=123456
|
REDIS_PASSWORD=123456
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# ==================== 镭射卡生成器 ====================
|
||||||
|
# LASER_GEN_PROVIDER:
|
||||||
|
# minimax (默认) - 后端直连 MiniMax
|
||||||
|
# dify - 调 Dify laser_card_variants_v1 工作流 (阻塞)
|
||||||
|
# openai - 后端直连 OpenAI /v1/images/edits (阻塞, 5 路并发 + 直接落 OSS)
|
||||||
|
LASER_GEN_PROVIDER=minimax
|
||||||
|
|
||||||
|
# ==================== OpenAI Images API (LASER_GEN_PROVIDER=openai 时使用) ====================
|
||||||
|
# 生产环境必须设置,否则 LASER_GEN_PROVIDER=openai 启动会直接报错
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-image-1.5
|
||||||
|
|
||||||
# ==================== Dify Workflow (laser_card_v1) ====================
|
# ==================== Dify Workflow (laser_card_v1) ====================
|
||||||
# 注意:当前 laser 卡生成走的是 gateway 内的 MiniMax 直接调用(不是 Dify),
|
# Dify API 入口 (laser_card_variants_v1 工作流, 仅在 LASER_GEN_PROVIDER=dify 时使用)
|
||||||
# 这两个变量仅为 Dify 工作流调用保留,目前未使用
|
DIFY_API_BASE=http://localhost/v1
|
||||||
DIFY_API_BASE=https://api.dify.ai/v1
|
DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
|
||||||
DIFY_API_KEY=
|
|
||||||
|
|
||||||
# ==================== Laser Card ====================
|
# ==================== Laser Card ====================
|
||||||
# 镭射卡 6 层合成服务(gateway 通过容器名 + 端口访问)
|
# 镭射卡 6 层合成服务(gateway 通过容器名 + 端口访问)
|
||||||
|
|||||||
@ -53,9 +53,6 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
|
|||||||
-o /tmp/aichatservice services/aiChatService/main.go && \
|
-o /tmp/aichatservice services/aiChatService/main.go && \
|
||||||
echo "Built aichatservice" && \
|
echo "Built aichatservice" && \
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
|
||||||
-o /tmp/lasercompositor services/laserCompositor/main.go && \
|
|
||||||
echo "Built lasercompositor" && \
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" \
|
|
||||||
-o /tmp/statisticservice services/statisticService/main.go && \
|
-o /tmp/statisticservice services/statisticService/main.go && \
|
||||||
echo "Built statisticservice"
|
echo "Built statisticservice"
|
||||||
|
|
||||||
@ -196,20 +193,7 @@ HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
|
|||||||
|
|
||||||
ENTRYPOINT ["/app/aichatservice"]
|
ENTRYPOINT ["/app/aichatservice"]
|
||||||
|
|
||||||
# ---- Runtime Stage: LaserCompositor (镭射卡 6 层合成服务) ----
|
|
||||||
FROM --platform=linux/amd64 alpine:3.19 AS lasercompositor
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /tmp/lasercompositor /app/lasercompositor
|
|
||||||
|
|
||||||
EXPOSE 7002
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:7002/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/lasercompositor"]
|
|
||||||
|
|
||||||
# ---- Runtime Stage: StatisticService (数据看板微服务) ----
|
# ---- Runtime Stage: StatisticService (数据看板微服务) ----
|
||||||
FROM --platform=linux/amd64 alpine:3.19 AS statisticservice
|
FROM --platform=linux/amd64 alpine:3.19 AS statisticservice
|
||||||
|
|||||||
@ -298,40 +298,6 @@ services:
|
|||||||
reservations:
|
reservations:
|
||||||
memory: 256M
|
memory: 256M
|
||||||
|
|
||||||
# ==================== Laser Compositor Service (镭射卡 6 层合成) ====================
|
|
||||||
lasercompositor:
|
|
||||||
image: topfans/lasercompositor:latest
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.services
|
|
||||||
target: lasercompositor
|
|
||||||
# 跳过 pull(本地无 push 到 registry),直接走 build
|
|
||||||
pull_policy: never
|
|
||||||
container_name: topfans-lasercompositor
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
<<: *common-env
|
|
||||||
COMPOSITOR_PORT: 7002
|
|
||||||
OSS_REGION: ${OSS_REGION:-cn-shanghai}
|
|
||||||
OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-top-fans-test}
|
|
||||||
OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-}
|
|
||||||
OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-}
|
|
||||||
OSS_ASSET_DIR: ${OSS_ASSET_DIR:-asset/}
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
networks:
|
|
||||||
- topfans-net
|
|
||||||
expose:
|
|
||||||
- "7002"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "nc -z localhost 7002 || exit 1"]
|
|
||||||
<<: *healthcheck
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
|
||||||
|
|
||||||
# ==================== Statistic Service (数据看板微服务) ====================
|
# ==================== Statistic Service (数据看板微服务) ====================
|
||||||
statisticservice:
|
statisticservice:
|
||||||
@ -405,7 +371,6 @@ services:
|
|||||||
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
|
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
|
||||||
MINIMAX_API_URL: ${MINIMAX_API_URL:-https://api.minimaxi.com/v1/image_generation}
|
MINIMAX_API_URL: ${MINIMAX_API_URL:-https://api.minimaxi.com/v1/image_generation}
|
||||||
# 镭射卡 6 层合成微服务(容器内通过 service name 访问)
|
# 镭射卡 6 层合成微服务(容器内通过 service name 访问)
|
||||||
LASER_COMPOSITOR_URL: http://lasercompositor:7002
|
|
||||||
# 抠图(人像扣底)
|
# 抠图(人像扣底)
|
||||||
SEGMENT_PROVIDER: ${SEGMENT_PROVIDER:-imageseg}
|
SEGMENT_PROVIDER: ${SEGMENT_PROVIDER:-imageseg}
|
||||||
# OSS 配置(gateway 用于签名 + 抠图上传)
|
# OSS 配置(gateway 用于签名 + 抠图上传)
|
||||||
@ -436,8 +401,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
statisticservice:
|
statisticservice:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
lasercompositor:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
topfans-net:
|
topfans-net:
|
||||||
aliases:
|
aliases:
|
||||||
|
|||||||
@ -429,39 +429,6 @@ services:
|
|||||||
memory: 256M
|
memory: 256M
|
||||||
cpus: '0.5'
|
cpus: '0.5'
|
||||||
|
|
||||||
# ==================== Laser Compositor Service (镭射卡 6 层合成) ====================
|
|
||||||
lasercompositor:
|
|
||||||
image: topfans/lasercompositor:latest
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.services
|
|
||||||
target: lasercompositor
|
|
||||||
# 跳过 pull(本地无 push 到 registry),直接走 build
|
|
||||||
pull_policy: never
|
|
||||||
container_name: topfans-lasercompositor
|
|
||||||
restart: always
|
|
||||||
env_file:
|
|
||||||
- .env.prod
|
|
||||||
environment:
|
|
||||||
<<: *common-env
|
|
||||||
COMPOSITOR_PORT: 7002
|
|
||||||
# OSS_* 全部走 env_file: .env.prod
|
|
||||||
networks:
|
|
||||||
- topfans-net
|
|
||||||
expose:
|
|
||||||
- "7002"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7002/health || exit 1"]
|
|
||||||
<<: *healthcheck
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 300M
|
|
||||||
cpus: '0.5'
|
|
||||||
reservations:
|
|
||||||
memory: 128M
|
|
||||||
cpus: '0.25'
|
|
||||||
|
|
||||||
# ==================== Statistic Service (数据看板微服务) ====================
|
# ==================== Statistic Service (数据看板微服务) ====================
|
||||||
statisticservice:
|
statisticservice:
|
||||||
image: topfans/statisticservice:latest
|
image: topfans/statisticservice:latest
|
||||||
@ -535,7 +502,6 @@ services:
|
|||||||
DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20005
|
DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20005
|
||||||
DUBBO_AI_CHAT_SERVICE_URL: tri://aichatservice:20008
|
DUBBO_AI_CHAT_SERVICE_URL: tri://aichatservice:20008
|
||||||
DUBBO_STATISTIC_SERVICE_URL: tri://statisticservice:20009
|
DUBBO_STATISTIC_SERVICE_URL: tri://statisticservice:20009
|
||||||
LASER_COMPOSITOR_URL: http://lasercompositor:7002
|
|
||||||
# 抠图(人像扣底)、OSS、Dify、JWT、Redis 全部走 env_file: .env.prod
|
# 抠图(人像扣底)、OSS、Dify、JWT、Redis 全部走 env_file: .env.prod
|
||||||
REDIS_HOST: topfans-redis
|
REDIS_HOST: topfans-redis
|
||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
@ -559,7 +525,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
statisticservice:
|
statisticservice:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
lasercompositor:
|
|
||||||
condition: service_started
|
condition: service_started
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@ -225,7 +225,7 @@ workflow:
|
|||||||
max_write_timeout: 20
|
max_write_timeout: 20
|
||||||
title: 合成镭射卡
|
title: 合成镭射卡
|
||||||
type: http-request
|
type: http-request
|
||||||
url: '{{#env.LASER_COMPOSITOR_HOST#}}/compose'
|
url: '{{#env.LASER_COMPOSITOR_HOST#}}'
|
||||||
variables: []
|
variables: []
|
||||||
height: 93
|
height: 93
|
||||||
id: compositor
|
id: compositor
|
||||||
|
|||||||
@ -6,18 +6,18 @@ app:
|
|||||||
name: laser_card_variants_v1
|
name: laser_card_variants_v1
|
||||||
use_icon_as_answer_icon: false
|
use_icon_as_answer_icon: false
|
||||||
kind: app
|
kind: app
|
||||||
version: 0.1.4
|
version: 0.1.6
|
||||||
workflow:
|
workflow:
|
||||||
conversation_variables: []
|
conversation_variables: []
|
||||||
environment_variables:
|
environment_variables:
|
||||||
- description: 'laser-compositor 合成服务地址'
|
- description: 'laser-compositor 合成服务地址'
|
||||||
name: LASER_COMPOSITOR_HOST
|
name: LASER_COMPOSITOR_HOST
|
||||||
value: 'http://host.docker.internal:7000'
|
value: 'http://host.docker.internal:18090/api/v1/laser'
|
||||||
value_type: string
|
value_type: string
|
||||||
- description: 'MiniMax API 密钥'
|
- description: 'MiniMax API 密钥'
|
||||||
name: MINIMAX_API_KEY
|
name: MINIMAX_API_KEY
|
||||||
value: ''
|
value: 'sk-api-oezuuNMr5iwPdlJ1JgTJTSzhMhGtaUR5Odjjg0ZqVQ7MoMIqLuE_ginMWRkNiAiDgMY6MvTVkYCWSQ8SK1-LuldrFmohCHxCgZIbxsFYr9zxA8z08Eb8nbo'
|
||||||
value_type: secret
|
value_type: string
|
||||||
features:
|
features:
|
||||||
file_upload:
|
file_upload:
|
||||||
image:
|
image:
|
||||||
@ -42,7 +42,7 @@ workflow:
|
|||||||
voice: ''
|
voice: ''
|
||||||
graph:
|
graph:
|
||||||
edges:
|
edges:
|
||||||
# start → 参数展开
|
# start → code-param
|
||||||
- data:
|
- data:
|
||||||
isInIteration: false
|
isInIteration: false
|
||||||
isInLoop: false
|
isInLoop: false
|
||||||
@ -55,20 +55,85 @@ workflow:
|
|||||||
targetHandle: target
|
targetHandle: target
|
||||||
type: custom
|
type: custom
|
||||||
zIndex: 0
|
zIndex: 0
|
||||||
# 参数展开 → 循环
|
# code-param → http-bg-all(一次生成 5 张背景图)
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: code
|
||||||
|
targetType: http-request
|
||||||
|
id: code-param-source-http-bg-all-target
|
||||||
|
source: code-param
|
||||||
|
sourceHandle: source
|
||||||
|
target: http-bg-all
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
# code-param → http-overlay-all(一次生成 5 张装饰图)
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: code
|
||||||
|
targetType: http-request
|
||||||
|
id: code-param-source-http-overlay-all-target
|
||||||
|
source: code-param
|
||||||
|
sourceHandle: source
|
||||||
|
target: http-overlay-all
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
# code-param → code-prepare(传递 variants 元数据)
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: code
|
||||||
|
targetType: code
|
||||||
|
id: code-param-source-code-prepare-target
|
||||||
|
source: code-param
|
||||||
|
sourceHandle: source
|
||||||
|
target: code-prepare
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
# http-bg-all → code-prepare
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: http-request
|
||||||
|
targetType: code
|
||||||
|
id: http-bg-all-source-code-prepare-target
|
||||||
|
source: http-bg-all
|
||||||
|
sourceHandle: source
|
||||||
|
target: code-prepare
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
# http-overlay-all → code-prepare
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: http-request
|
||||||
|
targetType: code
|
||||||
|
id: http-overlay-all-source-code-prepare-target
|
||||||
|
source: http-overlay-all
|
||||||
|
sourceHandle: source
|
||||||
|
target: code-prepare
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
# code-prepare → loop-variants
|
||||||
- data:
|
- data:
|
||||||
isInIteration: false
|
isInIteration: false
|
||||||
isInLoop: false
|
isInLoop: false
|
||||||
sourceType: code
|
sourceType: code
|
||||||
targetType: iteration
|
targetType: iteration
|
||||||
id: code-param-source-loop-variants-target
|
id: code-prepare-source-loop-variants-target
|
||||||
source: code-param
|
source: code-prepare
|
||||||
sourceHandle: source
|
sourceHandle: source
|
||||||
target: loop-variants
|
target: loop-variants
|
||||||
targetHandle: target
|
targetHandle: target
|
||||||
type: custom
|
type: custom
|
||||||
zIndex: 0
|
zIndex: 0
|
||||||
# 循环 → 聚合
|
# loop-variants → code-agg
|
||||||
- data:
|
- data:
|
||||||
isInIteration: false
|
isInIteration: false
|
||||||
isInLoop: false
|
isInLoop: false
|
||||||
@ -81,7 +146,7 @@ workflow:
|
|||||||
targetHandle: target
|
targetHandle: target
|
||||||
type: custom
|
type: custom
|
||||||
zIndex: 0
|
zIndex: 0
|
||||||
# 聚合 → 结束
|
# code-agg → end
|
||||||
- data:
|
- data:
|
||||||
isInIteration: false
|
isInIteration: false
|
||||||
isInLoop: false
|
isInLoop: false
|
||||||
@ -95,45 +160,17 @@ workflow:
|
|||||||
type: custom
|
type: custom
|
||||||
zIndex: 0
|
zIndex: 0
|
||||||
# === 循环内部 ===
|
# === 循环内部 ===
|
||||||
# iter-start → MiniMax 背景
|
# iter-start → code-call-compositor(直接合成,bg/overlay 已预生成)
|
||||||
- data:
|
- data:
|
||||||
isInIteration: true
|
isInIteration: true
|
||||||
iteration_id: loop-variants
|
iteration_id: loop-variants
|
||||||
isInLoop: false
|
isInLoop: false
|
||||||
sourceType: start
|
sourceType: start
|
||||||
targetType: http-request
|
targetType: code
|
||||||
id: iter-start-source-http-bg-target
|
id: iter-start-source-code-call-compositor-target
|
||||||
source: iter-start
|
source: iter-start
|
||||||
sourceHandle: source
|
sourceHandle: source
|
||||||
target: http-bg
|
target: code-call-compositor
|
||||||
targetHandle: target
|
|
||||||
type: custom
|
|
||||||
zIndex: 1000
|
|
||||||
# MiniMax 背景 → MiniMax 装饰
|
|
||||||
- data:
|
|
||||||
isInIteration: true
|
|
||||||
iteration_id: loop-variants
|
|
||||||
isInLoop: false
|
|
||||||
sourceType: http-request
|
|
||||||
targetType: http-request
|
|
||||||
id: http-bg-source-http-overlay-target
|
|
||||||
source: http-bg
|
|
||||||
sourceHandle: source
|
|
||||||
target: http-overlay
|
|
||||||
targetHandle: target
|
|
||||||
type: custom
|
|
||||||
zIndex: 1000
|
|
||||||
# MiniMax 装饰 → compositor
|
|
||||||
- data:
|
|
||||||
isInIteration: true
|
|
||||||
iteration_id: loop-variants
|
|
||||||
isInLoop: false
|
|
||||||
sourceType: http-request
|
|
||||||
targetType: http-request
|
|
||||||
id: http-overlay-source-http-compositor-target
|
|
||||||
source: http-overlay
|
|
||||||
sourceHandle: source
|
|
||||||
target: http-compositor
|
|
||||||
targetHandle: target
|
targetHandle: target
|
||||||
type: custom
|
type: custom
|
||||||
zIndex: 1000
|
zIndex: 1000
|
||||||
@ -180,13 +217,13 @@ workflow:
|
|||||||
type: custom
|
type: custom
|
||||||
width: 244
|
width: 244
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 代码节点 — 参数展开
|
# 代码节点 — 参数展开(同时输出合并的 prompt 字符串)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
- data:
|
- data:
|
||||||
code: "import json\n\ndef main(preset_codes: str, render_configs: str) -> dict:\n preset_list = []\n if preset_codes and preset_codes.strip():\n try:\n parsed = json.loads(preset_codes)\n if isinstance(parsed, list):\n preset_list = parsed\n except:\n pass\n if not preset_list:\n preset_list = [\"dream\", \"classic\", \"holoFull\", \"ice\", \"sunset\"]\n configs = []\n if render_configs and render_configs.strip():\n try:\n parsed = json.loads(render_configs)\n if isinstance(parsed, list):\n configs = parsed\n except:\n pass\n config_map = {}\n for rc in configs:\n if isinstance(rc, dict) and \"preset_id\" in rc:\n config_map[rc[\"preset_id\"]] = rc\n variants = []\n for pc in preset_list:\n if pc in config_map:\n cfg = config_map[pc]\n variants.append({\n \"preset_id\": pc,\n \"grating_config\": cfg.get(\"grating_config\", {}),\n \"bg_prompt\": cfg.get(\"bg_prompt\", \"\"),\n \"overlay_prompt\": cfg.get(\"overlay_prompt\", \"\"),\n })\n return {\n \"variants\": variants,\n \"variant_count\": len(variants),\n }\n"
|
code: "import json\n\ndef main(preset_codes: str, render_configs: str) -> dict:\n preset_list = []\n if preset_codes and preset_codes.strip():\n try:\n parsed = json.loads(preset_codes)\n if isinstance(parsed, list):\n preset_list = parsed\n except:\n pass\n if not preset_list:\n preset_list = [\"dream\", \"classic\", \"holoFull\", \"ice\", \"sunset\"]\n configs = []\n if render_configs and render_configs.strip():\n try:\n parsed = json.loads(render_configs)\n if isinstance(parsed, list):\n configs = parsed\n except:\n pass\n config_map = {}\n for rc in configs:\n if isinstance(rc, dict) and \"preset_id\" in rc:\n config_map[rc[\"preset_id\"]] = rc\n variants = []\n for idx, pc in enumerate(preset_list):\n if pc in config_map:\n cfg = config_map[pc]\n variants.append({\n \"preset_id\": pc,\n \"variant_index\": idx,\n \"grating_config\": cfg.get(\"grating_config\", {}),\n \"bg_prompt\": cfg.get(\"bg_prompt\", \"\"),\n \"overlay_prompt\": cfg.get(\"overlay_prompt\", \"\"),\n })\n bg_parts = [v.get(\"bg_prompt\", \"\") or \"\" for v in variants]\n ov_parts = [v.get(\"overlay_prompt\", \"\") or \"\" for v in variants]\n style_names = {\"dream\": \"梦幻\", \"classic\": \"经典\", \"holoFull\": \"全息炫彩\", \"ice\": \"冰晶\", \"sunset\": \"落日\"}\n style_list = [style_names.get(v[\"preset_id\"], v[\"preset_id\"]) for v in variants]\n bg_descs = [bp if bp else f\"{sn}风格渐变背景\" for sn, bp in zip(style_list, bg_parts)]\n ov_descs = [op if op else f\"柔和光晕\" for sn, op in zip(style_list, ov_parts)]\n bg_prompts_all = \" | \".join(bg_descs)\n overlay_prompts_all = \" | \".join(ov_descs)\n return {\n \"variants\": variants,\n \"variant_count\": len(variants),\n \"bg_prompts_all\": bg_prompts_all,\n \"overlay_prompts_all\": overlay_prompts_all,\n }\n"
|
||||||
code_language: python3
|
code_language: python3
|
||||||
dependencies: []
|
dependencies: []
|
||||||
desc: '解析 JSON 字符串 → 构建 variants 列表'
|
desc: '解析 JSON 字符串 → 构建 variants 列表 + 合并 prompt'
|
||||||
outputs:
|
outputs:
|
||||||
variant_count:
|
variant_count:
|
||||||
children: null
|
children: null
|
||||||
@ -194,6 +231,12 @@ workflow:
|
|||||||
variants:
|
variants:
|
||||||
children: null
|
children: null
|
||||||
type: array[object]
|
type: array[object]
|
||||||
|
bg_prompts_all:
|
||||||
|
children: null
|
||||||
|
type: string
|
||||||
|
overlay_prompts_all:
|
||||||
|
children: null
|
||||||
|
type: string
|
||||||
selected: false
|
selected: false
|
||||||
title: 参数展开
|
title: 参数展开
|
||||||
type: code
|
type: code
|
||||||
@ -206,7 +249,7 @@ workflow:
|
|||||||
- start
|
- start
|
||||||
- render_configs
|
- render_configs
|
||||||
variable: render_configs
|
variable: render_configs
|
||||||
height: 84
|
height: 120
|
||||||
id: code-param
|
id: code-param
|
||||||
position:
|
position:
|
||||||
x: 320
|
x: 320
|
||||||
@ -220,34 +263,161 @@ workflow:
|
|||||||
type: custom
|
type: custom
|
||||||
width: 244
|
width: 244
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 循环节点
|
# 前置 HTTP — 一次生成 5 张背景图 (n=5)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
- data:
|
- data:
|
||||||
desc: '对每个 variant 分别生成背景图、装饰图、合成最终镭射卡'
|
authorization:
|
||||||
|
config:
|
||||||
|
api_key: '{{#env.MINIMAX_API_KEY#}}'
|
||||||
|
type: bearer
|
||||||
|
type: api-key
|
||||||
|
body:
|
||||||
|
data: '{"model": "image-01", "prompt": "Generate 5 distinct background images based on these style directions. Each background must be a clean empty backdrop (no people, no products, no holographic card objects, no product photography). Laser holographic effects will be applied in post-processing, so focus only on the artistic background direction. Background directions: {{#code-param.bg_prompts_all#}}", "aspect_ratio": "3:4", "n": 5, "prompt_optimizer": true, "response_format": "url"}'
|
||||||
|
type: json
|
||||||
|
desc: '一次生成 5 种不同风格的背景图(干净背景,镭射效果后处理)'
|
||||||
|
headers: 'Content-Type: application/json'
|
||||||
|
isInIteration: false
|
||||||
|
method: post
|
||||||
|
params: ''
|
||||||
|
selected: false
|
||||||
|
timeout:
|
||||||
|
max_connect_timeout: 10
|
||||||
|
max_read_timeout: 200
|
||||||
|
max_write_timeout: 30
|
||||||
|
title: MiniMax 批量背景图
|
||||||
|
type: http-request
|
||||||
|
url: 'https://api.minimaxi.com/v1/image_generation'
|
||||||
|
variables:
|
||||||
|
- value_selector:
|
||||||
|
- code-param
|
||||||
|
- bg_prompts_all
|
||||||
|
variable: bg_prompts_all
|
||||||
|
height: 93
|
||||||
|
id: http-bg-all
|
||||||
|
position:
|
||||||
|
x: 610
|
||||||
|
y: 180
|
||||||
|
positionAbsolute:
|
||||||
|
x: 610
|
||||||
|
y: 180
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 244
|
||||||
|
# ================================================================
|
||||||
|
# 前置 HTTP — 一次生成 5 张装饰图 (n=5)
|
||||||
|
# ================================================================
|
||||||
|
- data:
|
||||||
|
authorization:
|
||||||
|
config:
|
||||||
|
api_key: '{{#env.MINIMAX_API_KEY#}}'
|
||||||
|
type: bearer
|
||||||
|
type: api-key
|
||||||
|
body:
|
||||||
|
data: '{"model": "image-01", "prompt": "Generate 5 distinct transparent overlay decoration layers. Each overlay must be a clean transparent decoration on empty background (no people, no products, no holographic card objects). Soft glows, micro-particles, light flow effects that enhance atmosphere without occluding main subject. Overlay directions: {{#code-param.overlay_prompts_all#}}", "aspect_ratio": "3:4", "n": 5, "prompt_optimizer": true, "response_format": "url"}'
|
||||||
|
type: json
|
||||||
|
desc: '一次生成 5 种不同风格的装饰层(透明叠加,不承载主题,镭射效果由 compositor 保证)'
|
||||||
|
headers: 'Content-Type: application/json'
|
||||||
|
isInIteration: false
|
||||||
|
method: post
|
||||||
|
params: ''
|
||||||
|
selected: false
|
||||||
|
timeout:
|
||||||
|
max_connect_timeout: 10
|
||||||
|
max_read_timeout: 200
|
||||||
|
max_write_timeout: 30
|
||||||
|
title: MiniMax 批量装饰图
|
||||||
|
type: http-request
|
||||||
|
url: 'https://api.minimaxi.com/v1/image_generation'
|
||||||
|
variables:
|
||||||
|
- value_selector:
|
||||||
|
- code-param
|
||||||
|
- overlay_prompts_all
|
||||||
|
variable: overlay_prompts_all
|
||||||
|
height: 93
|
||||||
|
id: http-overlay-all
|
||||||
|
position:
|
||||||
|
x: 610
|
||||||
|
y: 420
|
||||||
|
positionAbsolute:
|
||||||
|
x: 610
|
||||||
|
y: 420
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 244
|
||||||
|
# ================================================================
|
||||||
|
# 代码节点 — 将 bg/overlay URLs 与 variants 配对
|
||||||
|
# ================================================================
|
||||||
|
- data:
|
||||||
|
code: "import json\n\ndef main(bg_body: str, overlay_body: str, variants: list) -> dict:\n # 解析 MiniMax 批量响应,提取 5 张图 URL\n bg_data = json.loads(bg_body) if isinstance(bg_body, str) else bg_body\n bg_urls = (bg_data.get(\"data\") or {}).get(\"image_urls\", [])\n ov_data = json.loads(overlay_body) if isinstance(overlay_body, str) else overlay_body\n overlay_urls = (ov_data.get(\"data\") or {}).get(\"image_urls\", [])\n\n enriched = []\n for i, v in enumerate(variants):\n enriched.append({\n \"preset_id\": v.get(\"preset_id\", f\"v_{i}\"),\n \"variant_index\": v.get(\"variant_index\", i),\n \"grating_config\": v.get(\"grating_config\", {}),\n \"bg_prompt\": v.get(\"bg_prompt\", \"\"),\n \"overlay_prompt\": v.get(\"overlay_prompt\", \"\"),\n \"bg_url\": bg_urls[i] if i < len(bg_urls) else \"\",\n \"overlay_url\": overlay_urls[i] if i < len(overlay_urls) else \"\",\n })\n return {\"enriched_variants\": enriched}\n"
|
||||||
|
code_language: python3
|
||||||
|
dependencies: []
|
||||||
|
desc: '解析 MiniMax 批量响应 → 将 5 bg_urls + 5 overlay_urls 配对到每个 variant'
|
||||||
|
outputs:
|
||||||
|
enriched_variants:
|
||||||
|
children: null
|
||||||
|
type: array[object]
|
||||||
|
selected: false
|
||||||
|
title: 批量结果配对
|
||||||
|
type: code
|
||||||
|
variables:
|
||||||
|
- value_selector:
|
||||||
|
- http-overlay-all
|
||||||
|
- body
|
||||||
|
variable: overlay_body
|
||||||
|
- value_selector:
|
||||||
|
- http-bg-all
|
||||||
|
- body
|
||||||
|
variable: bg_body
|
||||||
|
- value_selector:
|
||||||
|
- code-param
|
||||||
|
- variants
|
||||||
|
variable: variants
|
||||||
|
height: 84
|
||||||
|
id: code-prepare
|
||||||
|
position:
|
||||||
|
x: 900
|
||||||
|
y: 300
|
||||||
|
positionAbsolute:
|
||||||
|
x: 900
|
||||||
|
y: 300
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 244
|
||||||
|
# ================================================================
|
||||||
|
# 循环节点(并行 5)
|
||||||
|
# ================================================================
|
||||||
|
- data:
|
||||||
|
desc: '对每个 variant 并行合成最终镭射卡'
|
||||||
error_handle_mode: terminated
|
error_handle_mode: terminated
|
||||||
height: 351
|
height: 351
|
||||||
iterator_selector:
|
iterator_selector:
|
||||||
- code-param
|
- code-prepare
|
||||||
- variants
|
- enriched_variants
|
||||||
is_parallel: false
|
is_parallel: true
|
||||||
output_selector:
|
output_selector:
|
||||||
- http-compositor
|
- code-call-compositor
|
||||||
- body
|
- compose_result
|
||||||
output_type: array[string]
|
output_type: array[string]
|
||||||
parallel_nums: 1
|
parallel_nums: 5
|
||||||
selected: false
|
selected: false
|
||||||
startNodeType: start
|
startNodeType: start
|
||||||
start_node_id: iter-start
|
start_node_id: iter-start
|
||||||
title: 遍历 Variants
|
title: 遍历 Variants(并行5)
|
||||||
type: iteration
|
type: iteration
|
||||||
width: 953
|
width: 953
|
||||||
height: 351
|
height: 351
|
||||||
id: loop-variants
|
id: loop-variants
|
||||||
position:
|
position:
|
||||||
x: 610
|
x: 1200
|
||||||
y: 300
|
y: 300
|
||||||
positionAbsolute:
|
positionAbsolute:
|
||||||
x: 610
|
x: 1200
|
||||||
y: 300
|
y: 300
|
||||||
selected: false
|
selected: false
|
||||||
sourcePosition: right
|
sourcePosition: right
|
||||||
@ -259,7 +429,7 @@ workflow:
|
|||||||
# 循环内部 — 开始
|
# 循环内部 — 开始
|
||||||
# ================================================================
|
# ================================================================
|
||||||
- data:
|
- data:
|
||||||
desc: 当前 variant_item
|
desc: 当前 variant_item(含预生成的 bg_url / overlay_url)
|
||||||
isInIteration: true
|
isInIteration: true
|
||||||
iteration_id: loop-variants
|
iteration_id: loop-variants
|
||||||
selected: false
|
selected: false
|
||||||
@ -274,7 +444,7 @@ workflow:
|
|||||||
x: 87
|
x: 87
|
||||||
y: 120
|
y: 120
|
||||||
positionAbsolute:
|
positionAbsolute:
|
||||||
x: 697
|
x: 1287
|
||||||
y: 420
|
y: 420
|
||||||
selected: false
|
selected: false
|
||||||
sourcePosition: right
|
sourcePosition: right
|
||||||
@ -283,122 +453,45 @@ workflow:
|
|||||||
width: 244
|
width: 244
|
||||||
zIndex: 1001
|
zIndex: 1001
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 循环内部 — MiniMax 背景
|
# 循环内部 — Code 节点:调 compositor 合成
|
||||||
|
# 直接从 enriched item 读取 bg_url / overlay_url,不再调 MiniMax
|
||||||
# ================================================================
|
# ================================================================
|
||||||
- data:
|
- data:
|
||||||
authorization:
|
code: "import json\nimport requests\n\ndef main(\n cutout_url: str,\n item: dict,\n compositor_host: str\n) -> dict:\n \"\"\"\n 从 enriched item 读取预生成的 bg_url / overlay_url,\n 调 laser-compositor /compose 合成镭射卡。\n \"\"\"\n preset_id = item.get(\"preset_id\", \"unknown\")\n variant_index = item.get(\"variant_index\", 0)\n grating_config = item.get(\"grating_config\", {})\n bg_url = item.get(\"bg_url\", \"\")\n overlay_url = item.get(\"overlay_url\", \"\")\n\n compose_body = {\n \"background_url\": bg_url,\n \"cutout_url\": cutout_url,\n \"overlay_url\": overlay_url,\n \"grating_config\": grating_config,\n \"export_width\": 450,\n \"export_height\": 600,\n \"variant_index\": variant_index,\n \"output_oss_key\": f\"laser-card/dify/{preset_id}\",\n }\n\n try:\n resp = requests.post(\n f\"{compositor_host}/compose\",\n json=compose_body,\n timeout=60,\n )\n resp.raise_for_status()\n compose_resp = resp.json()\n except Exception as e:\n return {\"compose_result\": json.dumps({\n \"status\": \"failed\",\n \"error\": f\"compositor call failed: {e}\",\n \"preset_id\": preset_id,\n \"variant_index\": variant_index,\n \"width\": 450,\n \"height\": 600,\n }, ensure_ascii=False)}\n\n compose_resp[\"preset_id\"] = preset_id\n return {\"compose_result\": json.dumps(compose_resp, ensure_ascii=False)}\n"
|
||||||
config:
|
code_language: python3
|
||||||
api_key: '{{#env.MINIMAX_API_KEY#}}'
|
dependencies:
|
||||||
type: bearer-api-key
|
- name: requests
|
||||||
body:
|
version: 2.32.3
|
||||||
data: '{"model": "image-01", "prompt": "{{#loop-variants.item.bg_prompt#}}", "aspect_ratio": "3:4", "n": 1, "prompt_optimizer": true, "response_format": "url"}'
|
desc: '用预生成的 bg_url/overlay_url → 调 compositor 合成 → 注入 preset_id'
|
||||||
type: json
|
outputs:
|
||||||
desc: '根据 bg_prompt 生成 3:4 背景'
|
compose_result:
|
||||||
headers: 'Content-Type: application/json'
|
children: null
|
||||||
isInIteration: true
|
type: string
|
||||||
iteration_id: loop-variants
|
|
||||||
method: post
|
|
||||||
params: ''
|
|
||||||
selected: false
|
selected: false
|
||||||
timeout:
|
title: 调 compositor 合成(批量优化版)
|
||||||
max_connect_timeout: 10
|
type: code
|
||||||
max_read_timeout: 60
|
variables:
|
||||||
max_write_timeout: 20
|
- value_selector:
|
||||||
title: MiniMax 背景图
|
- start
|
||||||
type: http-request
|
- cutout_url
|
||||||
url: 'https://api.minimaxi.com/v1/image_generation'
|
variable: cutout_url
|
||||||
variables: []
|
- value_selector:
|
||||||
|
- loop-variants
|
||||||
|
- item
|
||||||
|
variable: item
|
||||||
|
- value_selector:
|
||||||
|
- env
|
||||||
|
- LASER_COMPOSITOR_HOST
|
||||||
|
variable: compositor_host
|
||||||
extent: parent
|
extent: parent
|
||||||
height: 93
|
height: 93
|
||||||
id: http-bg
|
id: code-call-compositor
|
||||||
parentId: loop-variants
|
|
||||||
position:
|
|
||||||
x: 377
|
|
||||||
y: 120
|
|
||||||
positionAbsolute:
|
|
||||||
x: 987
|
|
||||||
y: 420
|
|
||||||
selected: false
|
|
||||||
sourcePosition: right
|
|
||||||
targetPosition: left
|
|
||||||
type: custom
|
|
||||||
width: 244
|
|
||||||
zIndex: 1001
|
|
||||||
# ================================================================
|
|
||||||
# 循环内部 — MiniMax 装饰
|
|
||||||
# ================================================================
|
|
||||||
- data:
|
|
||||||
authorization:
|
|
||||||
config:
|
|
||||||
api_key: '{{#env.MINIMAX_API_KEY#}}'
|
|
||||||
type: bearer-api-key
|
|
||||||
body:
|
|
||||||
data: '{"model": "image-01", "prompt": "{{#loop-variants.item.overlay_prompt#}}", "aspect_ratio": "3:4", "n": 1, "prompt_optimizer": true, "response_format": "url"}'
|
|
||||||
type: json
|
|
||||||
desc: '根据 overlay_prompt 生成透明装饰层'
|
|
||||||
headers: 'Content-Type: application/json'
|
|
||||||
isInIteration: true
|
|
||||||
iteration_id: loop-variants
|
|
||||||
method: post
|
|
||||||
params: ''
|
|
||||||
selected: false
|
|
||||||
timeout:
|
|
||||||
max_connect_timeout: 10
|
|
||||||
max_read_timeout: 60
|
|
||||||
max_write_timeout: 20
|
|
||||||
title: MiniMax 装饰图
|
|
||||||
type: http-request
|
|
||||||
url: 'https://api.minimaxi.com/v1/image_generation'
|
|
||||||
variables: []
|
|
||||||
extent: parent
|
|
||||||
height: 93
|
|
||||||
id: http-overlay
|
|
||||||
parentId: loop-variants
|
|
||||||
position:
|
|
||||||
x: 667
|
|
||||||
y: 120
|
|
||||||
positionAbsolute:
|
|
||||||
x: 1277
|
|
||||||
y: 420
|
|
||||||
selected: false
|
|
||||||
sourcePosition: right
|
|
||||||
targetPosition: left
|
|
||||||
type: custom
|
|
||||||
width: 244
|
|
||||||
zIndex: 1001
|
|
||||||
# ================================================================
|
|
||||||
# 循环内部 — compositor 合成
|
|
||||||
# ================================================================
|
|
||||||
- data:
|
|
||||||
authorization:
|
|
||||||
type: no-auth
|
|
||||||
body:
|
|
||||||
data: '{"background_url": "{{#http-bg.body.data.image_urls[0]#}}", "cutout_url": "{{#start.cutout_url#}}", "overlay_url": "{{#http-overlay.body.data.image_urls[0]#}}", "grating_config": {{#loop-variants.item.grating_config#}}, "export_width": 450, "export_height": 600, "output_oss_key": "laser-card/dify/{{#loop-variants.item.preset_id#}}"}'
|
|
||||||
type: json
|
|
||||||
desc: '调用 laser-compositor /compose 6层合成'
|
|
||||||
headers: 'Content-Type: application/json'
|
|
||||||
isInIteration: true
|
|
||||||
iteration_id: loop-variants
|
|
||||||
method: post
|
|
||||||
params: ''
|
|
||||||
selected: false
|
|
||||||
timeout:
|
|
||||||
max_connect_timeout: 10
|
|
||||||
max_read_timeout: 60
|
|
||||||
max_write_timeout: 20
|
|
||||||
title: 合成 6 层镭射卡
|
|
||||||
type: http-request
|
|
||||||
url: '{{#env.LASER_COMPOSITOR_HOST#}}/compose'
|
|
||||||
variables: []
|
|
||||||
extent: parent
|
|
||||||
height: 93
|
|
||||||
id: http-compositor
|
|
||||||
parentId: loop-variants
|
parentId: loop-variants
|
||||||
position:
|
position:
|
||||||
x: 957
|
x: 957
|
||||||
y: 120
|
y: 120
|
||||||
positionAbsolute:
|
positionAbsolute:
|
||||||
x: 1567
|
x: 1767
|
||||||
y: 420
|
y: 420
|
||||||
selected: false
|
selected: false
|
||||||
sourcePosition: right
|
sourcePosition: right
|
||||||
@ -410,7 +503,7 @@ workflow:
|
|||||||
# 代码节点 — 聚合输出
|
# 代码节点 — 聚合输出
|
||||||
# ================================================================
|
# ================================================================
|
||||||
- data:
|
- data:
|
||||||
code: "import json\n\ndef main(loop_output: list) -> dict:\n variants = []\n warnings = []\n for i, item in enumerate(loop_output or []):\n if isinstance(item, str):\n try:\n item = json.loads(item)\n except:\n pass\n if isinstance(item, dict) and item.get(\"status\") == \"succeeded\":\n variants.append({\n \"preset_id\": item.get(\"preset_id\", f\"variant_{i}\"),\n \"oss_key\": item.get(\"oss_key\", \"\"),\n \"signed_url\": item.get(\"signed_url\", \"\"),\n \"width\": item.get(\"width\", 450),\n \"height\": item.get(\"height\", 600),\n })\n else:\n warnings.append(f\"variant_{i} 合成失败\")\n return {\n \"status\": \"succeeded\" if len(variants) > 0 else \"failed\",\n \"variants\": json.dumps(variants),\n \"warnings\": json.dumps(warnings),\n }\n"
|
code: "import json\n\ndef main(loop_output: list) -> dict:\n variants = []\n warnings = []\n for i, item in enumerate(loop_output or []):\n if isinstance(item, str):\n try:\n item = json.loads(item)\n except:\n pass\n if isinstance(item, dict) and item.get(\"status\") == \"succeeded\":\n variants.append({\n \"preset_id\": item.get(\"preset_id\", f\"variant_{i}\"),\n \"oss_key\": item.get(\"oss_key\", \"\"),\n \"signed_url\": item.get(\"signed_url\", \"\"),\n \"width\": item.get(\"width\", 450),\n \"height\": item.get(\"height\", 600),\n })\n else:\n warnings.append(f\"{item.get('preset_id', f'variant_{i}')} 合成失败: {item.get('error', 'unknown')}\")\n return {\n \"status\": \"succeeded\" if len(variants) > 0 else \"failed\",\n \"variants\": json.dumps(variants, ensure_ascii=False),\n \"warnings\": json.dumps(warnings, ensure_ascii=False),\n }\n"
|
||||||
code_language: python3
|
code_language: python3
|
||||||
dependencies: []
|
dependencies: []
|
||||||
desc: '收集循环结果,输出统一 JSON'
|
desc: '收集循环结果,输出统一 JSON'
|
||||||
|
|||||||
190
docs/dify/laser_prompt_enhancer_v2.yml
Normal file
190
docs/dify/laser_prompt_enhancer_v2.yml
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
app:
|
||||||
|
description: 'GPT relay prompt enhancer — 通过中转站优化生图 prompt,单张调试版'
|
||||||
|
icon: ✨
|
||||||
|
icon_background: '#EBF5FF'
|
||||||
|
mode: workflow
|
||||||
|
name: laser_prompt_enhancer_v2
|
||||||
|
use_icon_as_answer_icon: false
|
||||||
|
kind: app
|
||||||
|
version: 0.3.0
|
||||||
|
dependencies:
|
||||||
|
- current_identifier: null
|
||||||
|
type: marketplace
|
||||||
|
value:
|
||||||
|
marketplace_plugin_unique_identifier: langgenius/openai_api_compatible
|
||||||
|
workflow:
|
||||||
|
conversation_variables: []
|
||||||
|
environment_variables: []
|
||||||
|
features:
|
||||||
|
file_upload:
|
||||||
|
image:
|
||||||
|
enabled: false
|
||||||
|
number_limits: 3
|
||||||
|
transfer_methods:
|
||||||
|
- local_file
|
||||||
|
- remote_url
|
||||||
|
opening_statement: ''
|
||||||
|
retriever_resource:
|
||||||
|
enabled: false
|
||||||
|
sensitive_word_avoidance:
|
||||||
|
enabled: false
|
||||||
|
speech_to_text:
|
||||||
|
enabled: false
|
||||||
|
suggested_questions: []
|
||||||
|
suggested_questions_after_answer:
|
||||||
|
enabled: false
|
||||||
|
text_to_speech:
|
||||||
|
enabled: false
|
||||||
|
language: ''
|
||||||
|
voice: ''
|
||||||
|
graph:
|
||||||
|
edges:
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: start
|
||||||
|
targetType: llm
|
||||||
|
id: start-source-llm-enhance-target
|
||||||
|
source: start
|
||||||
|
sourceHandle: source
|
||||||
|
target: llm-enhance
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: llm
|
||||||
|
targetType: end
|
||||||
|
id: llm-enhance-source-end-target
|
||||||
|
source: llm-enhance
|
||||||
|
sourceHandle: source
|
||||||
|
target: end
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
nodes:
|
||||||
|
# ================================================================
|
||||||
|
# 开始节点
|
||||||
|
# ================================================================
|
||||||
|
- data:
|
||||||
|
desc: '接收原始 bg_prompt 和用户风格描述'
|
||||||
|
selected: false
|
||||||
|
title: "开始"
|
||||||
|
type: start
|
||||||
|
variables:
|
||||||
|
- label: bg_prompt
|
||||||
|
max_length: 4096
|
||||||
|
options: []
|
||||||
|
required: true
|
||||||
|
type: text-input
|
||||||
|
variable: bg_prompt
|
||||||
|
- label: user_prompt
|
||||||
|
max_length: 1024
|
||||||
|
options: []
|
||||||
|
required: false
|
||||||
|
type: text-input
|
||||||
|
variable: user_prompt
|
||||||
|
height: 130
|
||||||
|
id: start
|
||||||
|
position:
|
||||||
|
x: 30
|
||||||
|
y: 300
|
||||||
|
positionAbsolute:
|
||||||
|
x: 30
|
||||||
|
y: 300
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 244
|
||||||
|
# ================================================================
|
||||||
|
# LLM 节点 — 通过中转站增强 prompt
|
||||||
|
# ================================================================
|
||||||
|
- data:
|
||||||
|
desc: '通过中转站的 LLM 优化 bg_prompt,输出增强后的单条 prompt'
|
||||||
|
selected: false
|
||||||
|
title: "中转站 LLM 增强"
|
||||||
|
type: llm
|
||||||
|
model:
|
||||||
|
provider: openai_api_compatible
|
||||||
|
name: gpt-4o
|
||||||
|
mode: chat
|
||||||
|
completion_params:
|
||||||
|
temperature: 0.7
|
||||||
|
max_tokens: 1024
|
||||||
|
prompt_template:
|
||||||
|
- role: system
|
||||||
|
text: |
|
||||||
|
You are a professional prompt engineer for the GPT image edit API (/v1/images/edits).
|
||||||
|
Rewrite a SINGLE image prompt into a detailed, vivid image editing instruction.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- The input image is the ORIGINAL full photo containing a person.
|
||||||
|
- Describe what background/style changes to apply around the person.
|
||||||
|
- KEEP THE ORIGINAL PERSON IDENTICAL (same face, same identity, same pose, same clothing). Only modify the background and decorative elements around them.
|
||||||
|
- Output only the enhanced prompt text, no markdown, no JSON wrapping.
|
||||||
|
- Maximum 2000 characters.
|
||||||
|
- role: user
|
||||||
|
text: |
|
||||||
|
Raw prompt: {{ start.bg_prompt }}
|
||||||
|
{% if start.user_prompt %}
|
||||||
|
User style direction: {{ start.user_prompt }}
|
||||||
|
{% endif %}
|
||||||
|
prompt_config:
|
||||||
|
jinja2_variables: []
|
||||||
|
memory:
|
||||||
|
role_prefix:
|
||||||
|
user: ''
|
||||||
|
assistant: ''
|
||||||
|
window:
|
||||||
|
enabled: false
|
||||||
|
query_prompt_template: ''
|
||||||
|
context:
|
||||||
|
enabled: false
|
||||||
|
vision:
|
||||||
|
enabled: false
|
||||||
|
configs:
|
||||||
|
variable_selector:
|
||||||
|
- sys
|
||||||
|
- files
|
||||||
|
detail: high
|
||||||
|
height: 180
|
||||||
|
id: llm-enhance
|
||||||
|
position:
|
||||||
|
x: 320
|
||||||
|
y: 300
|
||||||
|
positionAbsolute:
|
||||||
|
x: 320
|
||||||
|
y: 300
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 244
|
||||||
|
# ================================================================
|
||||||
|
# 结束节点
|
||||||
|
# ================================================================
|
||||||
|
- data:
|
||||||
|
desc: '返回增强后的 prompt'
|
||||||
|
outputs:
|
||||||
|
- value_selector:
|
||||||
|
- llm-enhance
|
||||||
|
- text
|
||||||
|
variable: enhanced_prompt
|
||||||
|
selected: false
|
||||||
|
title: "结束"
|
||||||
|
type: end
|
||||||
|
height: 90
|
||||||
|
id: end
|
||||||
|
position:
|
||||||
|
x: 610
|
||||||
|
y: 300
|
||||||
|
positionAbsolute:
|
||||||
|
x: 610
|
||||||
|
y: 300
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 244
|
||||||
@ -18,6 +18,9 @@
|
|||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<text v-else class="laser-preview-fallback-text">无预览</text>
|
<text v-else class="laser-preview-fallback-text">无预览</text>
|
||||||
|
<!-- 兜底时也保留镭射扫光感(CSS 动画,与 LaserVariantPyramid 一致) -->
|
||||||
|
<view v-if="variantPath || fallbackPath" class="laser-preview-fallback-shimmer" />
|
||||||
|
<view v-if="variantPath || fallbackPath" class="laser-preview-fallback-edge" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@ -289,6 +292,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.laser-preview-fallback {
|
.laser-preview-fallback {
|
||||||
|
position: relative;
|
||||||
width: 360px;
|
width: 360px;
|
||||||
height: 480px;
|
height: 480px;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
@ -308,5 +312,49 @@ onBeforeUnmount(() => {
|
|||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 兜底场景下的镭射扫光(WebGL 不可用时仍能看到动态光感) */
|
||||||
|
.laser-preview-fallback-shimmer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: linear-gradient(
|
||||||
|
115deg,
|
||||||
|
transparent 32%,
|
||||||
|
rgba(255, 255, 255, 0.18) 46%,
|
||||||
|
rgba(255, 255, 255, 0.32) 50%,
|
||||||
|
rgba(255, 255, 255, 0.18) 54%,
|
||||||
|
transparent 68%
|
||||||
|
);
|
||||||
|
background-size: 220% 220%;
|
||||||
|
background-position: -120% -120%;
|
||||||
|
animation: laser-preview-shimmer-sweep 8s ease-in-out infinite;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 兜底场景下的彩虹描边 */
|
||||||
|
.laser-preview-fallback-edge {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 8rpx rgba(200, 220, 240, 0.22),
|
||||||
|
inset 0 0 18rpx rgba(220, 230, 240, 0.12);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes laser-preview-shimmer-sweep {
|
||||||
|
0% {
|
||||||
|
background-position: -120% -120%;
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
background-position: -120% -120%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 220% 220%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -107,21 +107,29 @@ export function useLaserBatchGenerate(options = {}) {
|
|||||||
const imagePath = resolveImagePath(formData)
|
const imagePath = resolveImagePath(formData)
|
||||||
|
|
||||||
// Dify 模式:服务端 AI 生成
|
// Dify 模式:服务端 AI 生成
|
||||||
const genMode ='dify'
|
// genMode: 'dify' | 'openai' | 'client'
|
||||||
if (genMode === 'dify') {
|
// - dify: 调 Dify laser_card_variants_v1 工作流(后端 LASER_GEN_PROVIDER=dify)
|
||||||
|
// - openai: 调 OpenAI /v1/images/edits(后端 LASER_GEN_PROVIDER=openai,5 路并发 + 直接落 OSS)
|
||||||
|
// - client: 纯客户端 Canvas 合成
|
||||||
|
// 前端不感知 provider,只调 /api/v1/laser/generate,由后端按 env 路由
|
||||||
|
const genMode = 'openai'
|
||||||
|
if (genMode === 'dify' || genMode === 'openai') {
|
||||||
const dify = useLaserDifyGenerate()
|
const dify = useLaserDifyGenerate()
|
||||||
const userPrompt = (runOptions.userPrompt || formData?.userPrompt || '').trim()
|
// 表单实际存的字段是 aiDescription;为兼容旧调用方(runOptions.userPrompt / formData.userPrompt)同时 fallback
|
||||||
// 先做人像抠图,拿到 PNG 的 OSS 签名地址传给 compositor 叠加
|
const userPrompt = (runOptions.userPrompt || formData?.aiDescription || formData?.userPrompt || '').trim()
|
||||||
let cutoutUrl = ''
|
|
||||||
|
// 上传到 OSS 获取可访问 URL,供中转站下载原图
|
||||||
|
let imageUrlForAI = imagePath
|
||||||
try {
|
try {
|
||||||
const cutout = await segmentPortrait(imagePath, {
|
const uploadResult = await segmentPortrait(imagePath, {
|
||||||
imageBase64: formData.imageBase64 || formData.uploadedImageBase64 || '',
|
imageBase64: formData.imageBase64 || formData.uploadedImageBase64 || '',
|
||||||
})
|
})
|
||||||
cutoutUrl = cutout?.cutoutUrl || ''
|
imageUrlForAI = uploadResult?.originalUrl || uploadResult?.cutoutUrl || imagePath
|
||||||
} catch (segErr) {
|
} catch (segErr) {
|
||||||
console.warn('[useLaserBatchGenerate] 抠图失败,将不叠加人像:', segErr)
|
console.warn('[useLaserBatchGenerate] 上传 OSS 失败:', segErr)
|
||||||
|
throw segErr
|
||||||
}
|
}
|
||||||
await dify.submit(cutoutUrl, null, userPrompt)
|
await dify.submit(imageUrlForAI, null, userPrompt)
|
||||||
const infos = dify.getVariantInfos()
|
const infos = dify.getVariantInfos()
|
||||||
const instanceNoVal = dify.getInstanceNo()
|
const instanceNoVal = dify.getInstanceNo()
|
||||||
await waitMinDuration(minMs)
|
await waitMinDuration(minMs)
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Dify 镭射五图生成 composable
|
* 镭射五图生成 composable
|
||||||
* 通过 Gateway → Dify 工作流服务端生成镭射卡,替换客户端 Canvas 合成
|
* 兼容两种后端提供方:
|
||||||
|
* - Dify (LASER_GEN_PROVIDER=dify): 单次 blocking POST, 直接返回 variants
|
||||||
|
* - MiniMax (LASER_GEN_PROVIDER=minimax): 异步, POST 后轮询
|
||||||
*
|
*
|
||||||
* 模式切换:通过 VITE_LASER_GEN_MODE 环境变量控制
|
* 选择方式: 根据 POST 响应结构自动判断
|
||||||
* 'client' — 现有的客户端 laserBatchExport 路径
|
* - 含 job_id → 走轮询 (MiniMax 路径)
|
||||||
* 'dify' — 本模块,Gateway 触发 Dify 工作流
|
* - 含 variants → 走阻塞等待 (Dify 路径)
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getLaserApiBaseUrl } from '@/utils/api.js'
|
import { getLaserApiBaseUrl } from '@/utils/api.js'
|
||||||
import { buildRenderConfigs } from '@/utils/laser-card/laserPresets.js'
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 60000
|
||||||
|
const DIFY_BLOCKING_TIMEOUT_MS = 600000 // 中转站生图最慢可达 5min+, 客户端 600s 留余量
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 镭射专用请求(直接请求本地 Gateway,不走远程 DEV_BASE)
|
* 镭射专用请求(直接请求本地 Gateway,不走远程 DEV_BASE)
|
||||||
@ -30,7 +34,7 @@ async function laserRequest(opts) {
|
|||||||
method: opts.method || 'GET',
|
method: opts.method || 'GET',
|
||||||
data: opts.data || {},
|
data: opts.data || {},
|
||||||
header: headers,
|
header: headers,
|
||||||
timeout: 60000,
|
timeout: opts.timeout || DEFAULT_TIMEOUT_MS,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
reject(new Error(res.data?.message || `HTTP ${res.statusCode}`))
|
reject(new Error(res.data?.message || `HTTP ${res.statusCode}`))
|
||||||
@ -60,8 +64,62 @@ export function useLaserDifyGenerate() {
|
|||||||
let _resolveSubmit = null
|
let _resolveSubmit = null
|
||||||
let _rejectSubmit = null
|
let _rejectSubmit = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测字符串是否包含中文
|
||||||
|
*/
|
||||||
|
function containsChinese(text) {
|
||||||
|
return /[一-鿿㐀-䶿]/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 翻译中文到英文(使用 Google 免费翻译 API)
|
||||||
|
*/
|
||||||
|
async function translateToEnglish(text) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
'https://translate.googleapis.com/translate_a/single?client=gtx&sl=zh-CN&tl=en&dt=t&q=' +
|
||||||
|
encodeURIComponent(text)
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data && data[0]) {
|
||||||
|
return data[0].map(function(seg) { return seg[0] }).join(' ').trim()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[translate] failed, use original:', e)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 render_configs:完全由用户输入决定 prompt
|
||||||
|
* - 无抽卡、无风格池、无 grating_config
|
||||||
|
* - 用户输入中文自动翻译成英文
|
||||||
|
* - 每次返回 5 个 variant(同一 prompt,不同 preset_id)
|
||||||
|
*
|
||||||
|
* @param {string[]|null} _presetIds 忽略,保留参数兼容
|
||||||
|
* @param {string} userPrompt
|
||||||
|
* @returns {Promise<Array<{preset_id: string, bg_prompt: string}>>}
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
console.log('[translate] result:', prompt)
|
||||||
|
}
|
||||||
|
// 4 路并发 AI 生图 + 1 张原图 = 总共 5 张
|
||||||
|
return [1, 2, 3, 4].map(function(i) {
|
||||||
|
return {
|
||||||
|
preset_id: 'v' + i,
|
||||||
|
bg_prompt: prompt || 'Transform this into a premium holographic artwork',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交生成任务到 Gateway
|
* 提交生成任务到 Gateway
|
||||||
|
* 自动适配 Dify 阻塞 / MiniMax 异步两种模式
|
||||||
|
*
|
||||||
* @param {string} cutoutUrl 抠图后的 OSS URL(空串表示不叠加人像)
|
* @param {string} cutoutUrl 抠图后的 OSS URL(空串表示不叠加人像)
|
||||||
* @param {string[]} [presetIds] 需要生成的 preset ID 列表
|
* @param {string[]} [presetIds] 需要生成的 preset ID 列表
|
||||||
* @param {string} [userPrompt] 用户自定义 prompt,注入到 AI 生图
|
* @param {string} [userPrompt] 用户自定义 prompt,注入到 AI 生图
|
||||||
@ -71,35 +129,56 @@ export function useLaserDifyGenerate() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
|
|
||||||
const renderConfigs = buildRenderConfigs(presetIds || ['dream', 'classic', 'holoFull', 'ice', 'sunset'])
|
const renderConfigs = await resolveRenderConfigs(presetIds, userPrompt)
|
||||||
const presetCodes = renderConfigs.map((rc) => rc.preset_id)
|
const presetCodes = renderConfigs.map((rc) => rc.preset_id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Dify 阻塞模式单次请求可能耗时 30-120s, 先用较长 timeout
|
||||||
const res = await laserRequest({
|
const res = await laserRequest({
|
||||||
url: '/api/v1/laser/generate',
|
url: '/api/v1/laser/generate',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
timeout: DIFY_BLOCKING_TIMEOUT_MS,
|
||||||
data: {
|
data: {
|
||||||
cutout_url: cutoutUrl,
|
cutout_url: cutoutUrl,
|
||||||
preset_codes: presetCodes,
|
preset_codes: presetCodes,
|
||||||
render_configs: renderConfigs,
|
render_configs: renderConfigs,
|
||||||
user_prompt: userPrompt || '',
|
// userPrompt 已经包装进 render_configs.bg_prompt,这里传空避免后端二次拼接
|
||||||
|
user_prompt: '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.data || !res.data.job_id) {
|
if (!res || !res.data) {
|
||||||
throw new Error('生成任务创建失败:未返回 job_id')
|
throw new Error('生成任务失败: 响应为空')
|
||||||
}
|
}
|
||||||
|
|
||||||
jobId.value = res.data.job_id
|
const payload = res.data
|
||||||
status.value = 'processing'
|
|
||||||
pollingSince = Date.now()
|
|
||||||
|
|
||||||
// 等待轮询完成再 resolve
|
// 模式 1: Dify 阻塞 — 响应直接含 variants
|
||||||
await new Promise((resolve, reject) => {
|
if (payload.status === 'succeeded' && Array.isArray(payload.variants)) {
|
||||||
_resolveSubmit = resolve
|
applySucceeded(payload, cutoutUrl)
|
||||||
_rejectSubmit = reject
|
return
|
||||||
startPolling()
|
}
|
||||||
})
|
|
||||||
|
// 模式 1.5: Dify 失败
|
||||||
|
if (payload.status === 'failed') {
|
||||||
|
throw new Error(payload.error || 'Dify 工作流执行失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模式 2: MiniMax 异步 — 响应含 job_id, 进入轮询
|
||||||
|
if (payload.job_id) {
|
||||||
|
jobId.value = payload.job_id
|
||||||
|
status.value = 'processing'
|
||||||
|
progress.value = 0.05
|
||||||
|
pollingSince = Date.now()
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
_resolveSubmit = resolve
|
||||||
|
_rejectSubmit = reject
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('生成任务失败: 响应格式无法识别 (既无 job_id 也无 variants)')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.value = 'error'
|
status.value = 'error'
|
||||||
error.value = e
|
error.value = e
|
||||||
@ -107,6 +186,26 @@ export function useLaserDifyGenerate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Dify 阻塞模式的成功响应
|
||||||
|
*/
|
||||||
|
function applySucceeded(payload, fallbackCutoutUrl) {
|
||||||
|
var aiVariants = Array.isArray(payload.variants) ? payload.variants : []
|
||||||
|
cutoutUrl.value = payload.cutout_url || fallbackCutoutUrl || ''
|
||||||
|
// 把原图也作为一个 variant 展示(第 5 张)
|
||||||
|
if (cutoutUrl.value) {
|
||||||
|
aiVariants.push({
|
||||||
|
preset_id: 'original',
|
||||||
|
signed_url: cutoutUrl.value,
|
||||||
|
oss_key: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variants.value = aiVariants
|
||||||
|
instanceNo.value = payload.instance_no || ''
|
||||||
|
progress.value = 1.0
|
||||||
|
status.value = 'done'
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
const pollInterval = 2000 // 每 2 秒轮询
|
const pollInterval = 2000 // 每 2 秒轮询
|
||||||
const maxDuration = 120000 // 最长等待 120 秒
|
const maxDuration = 120000 // 最长等待 120 秒
|
||||||
@ -116,7 +215,7 @@ export function useLaserDifyGenerate() {
|
|||||||
|
|
||||||
// 超时降级
|
// 超时降级
|
||||||
if (Date.now() - pollingSince > maxDuration) {
|
if (Date.now() - pollingSince > maxDuration) {
|
||||||
const err = new Error('Dify 生成超时,已降级到本地合成')
|
const err = new Error('生成超时, 已降级到本地合成')
|
||||||
status.value = 'timeout'
|
status.value = 'timeout'
|
||||||
error.value = err
|
error.value = err
|
||||||
stopPolling()
|
stopPolling()
|
||||||
@ -129,7 +228,7 @@ export function useLaserDifyGenerate() {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.data) return
|
if (!res || !res.data) return
|
||||||
|
|
||||||
// 更新进度
|
// 更新进度
|
||||||
if (res.data.progress != null) {
|
if (res.data.progress != null) {
|
||||||
@ -137,22 +236,11 @@ export function useLaserDifyGenerate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.data.status === 'succeeded') {
|
if (res.data.status === 'succeeded') {
|
||||||
// 提取 variants
|
applySucceeded(res.data, '')
|
||||||
if (res.data.variants && Array.isArray(res.data.variants)) {
|
|
||||||
variants.value = res.data.variants
|
|
||||||
}
|
|
||||||
if (res.data.cutout_url) {
|
|
||||||
cutoutUrl.value = res.data.cutout_url
|
|
||||||
}
|
|
||||||
if (res.data.instance_no) {
|
|
||||||
instanceNo.value = res.data.instance_no
|
|
||||||
}
|
|
||||||
status.value = 'done'
|
|
||||||
progress.value = 1.0
|
|
||||||
stopPolling()
|
stopPolling()
|
||||||
if (_resolveSubmit) { _resolveSubmit(); _resolveSubmit = null }
|
if (_resolveSubmit) { _resolveSubmit(); _resolveSubmit = null }
|
||||||
} else if (res.data.status === 'failed') {
|
} else if (res.data.status === 'failed') {
|
||||||
const err = new Error(res.data.error || 'Dify 工作流执行失败')
|
const err = new Error(res.data.error || '生成任务失败')
|
||||||
status.value = 'error'
|
status.value = 'error'
|
||||||
error.value = err
|
error.value = err
|
||||||
stopPolling()
|
stopPolling()
|
||||||
@ -197,7 +285,7 @@ export function useLaserDifyGenerate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 variant 完整信息(含 oss_key),铸造时直接用 oss_key 避免 HEAD 请求
|
* 获取 variant 完整信息(含 oss_key),铸造时直接用 oss_key 避免 HEAD 请求
|
||||||
* @returns {Array<{ url: string, oss_key: string }>}
|
* @returns {Array<{ url: string, oss_key: string }>}
|
||||||
*/
|
*/
|
||||||
function getVariantInfos() {
|
function getVariantInfos() {
|
||||||
|
|||||||
@ -84,16 +84,24 @@ export async function segmentPortrait(imagePath, options = {}) {
|
|||||||
imageBase64: options.imageBase64 || '',
|
imageBase64: options.imageBase64 || '',
|
||||||
})
|
})
|
||||||
const data = res?.data
|
const data = res?.data
|
||||||
if (!data?.success || !data?.cutout_url_signed) {
|
|
||||||
const msg = data?.message || data?.error_code || 'LC_SEGMENT_FAILED'
|
// 只要原图上传到 OSS 就算成功,抠图失败不影响(OpenAI 模式不需要抠图)
|
||||||
throw new Error(msg)
|
// 只有连原图都没上传成功才抛错
|
||||||
|
if (!data?.success && !data?.original_url_signed) {
|
||||||
|
throw new Error(data?.message || data?.error_code || 'LC_SEGMENT_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抠图成功才下载存本地
|
||||||
|
let localPath = ''
|
||||||
|
if (data.cutout_url_signed) {
|
||||||
|
localPath = await downloadToLocal(data.cutout_url_signed)
|
||||||
|
persistCutoutToForm(localPath, data.cutout_oss_key || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const localPath = await downloadToLocal(data.cutout_url_signed)
|
|
||||||
persistCutoutToForm(localPath, data.cutout_oss_key || '')
|
|
||||||
return {
|
return {
|
||||||
localPath,
|
localPath,
|
||||||
ossKey: data.cutout_oss_key || '',
|
ossKey: data.cutout_oss_key || '',
|
||||||
cutoutUrl: data.cutout_url_signed || '', // OSS 签名的抠图 PNG 地址,供 Dify 使用
|
cutoutUrl: data.cutout_url_signed || '',
|
||||||
|
originalUrl: data.original_url_signed || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,14 @@ import App from './App'
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import './uni.promisify.adaptor'
|
import './uni.promisify.adaptor'
|
||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
|
Vue.config.errorHandler = function (err, vm, info) {
|
||||||
|
console.error('[GlobalErrorHandler]', err.message, info)
|
||||||
|
var tag = vm?.$options?.name || vm?.$options?._componentTag || vm?.$vnode?.tag || 'unknown'
|
||||||
|
console.error('[GlobalErrorHandler] component:', tag)
|
||||||
|
console.error('[GlobalErrorHandler] full stack:', err.stack)
|
||||||
|
// 开发模式下弹出通知
|
||||||
|
try { uni.showToast({ title: '组件渲染异常: ' + tag, icon: 'none', duration: 4000 }) } catch(e) {}
|
||||||
|
}
|
||||||
App.mpType = 'app'
|
App.mpType = 'app'
|
||||||
const app = new Vue({
|
const app = new Vue({
|
||||||
...App
|
...App
|
||||||
@ -16,6 +24,14 @@ import { createSSRApp } from 'vue'
|
|||||||
import store from './store'
|
import store from './store'
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = createSSRApp(App)
|
const app = createSSRApp(App)
|
||||||
|
app.config.errorHandler = function (err, vm, info) {
|
||||||
|
console.error('[GlobalErrorHandler]', err.message, info)
|
||||||
|
var tag = vm?.$options?.name || vm?.$options?._componentTag || vm?.$vnode?.tag || vm?.$el?.id || 'unknown'
|
||||||
|
console.error('[GlobalErrorHandler] component:', tag)
|
||||||
|
console.error('[GlobalErrorHandler] full stack:', err.stack)
|
||||||
|
// 开发模式下弹出通知
|
||||||
|
try { uni.showToast({ title: '组件渲染异常: ' + tag, icon: 'none', duration: 4000 }) } catch(e) {}
|
||||||
|
}
|
||||||
app.use(store)
|
app.use(store)
|
||||||
return {
|
return {
|
||||||
app
|
app
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name" : "TopFans",
|
"name" : "TopFans",
|
||||||
"appid" : "__UNI__F199FF4",
|
"appid" : "__UNI__8CBE431",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.0.5",
|
"versionName" : "1.0.5",
|
||||||
"versionCode" : 114,
|
"versionCode" : 114,
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/uni-cli-shared": "^3.0.0-alpha-5000120260205002",
|
||||||
|
"@dcloudio/vite-plugin-uni": "^3.0.0-alpha-5000120260205002",
|
||||||
|
"@vue/compiler-sfc": "^3.5.38",
|
||||||
|
"sass": "^1.101.0",
|
||||||
|
"vite": "^5.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,8 @@ export const PRESET_VARIANTS = [
|
|||||||
frostIntensity: 8,
|
frostIntensity: 8,
|
||||||
flowSpeed: 0.35,
|
flowSpeed: 0.35,
|
||||||
colorHint: '淡紫色 粉色 银色',
|
colorHint: '淡紫色 粉色 银色',
|
||||||
bgPrompt: '梦幻柔光渐变,淡紫色调和粉色,柔和泡泡和星光光斑,虚化景深,纯净梦幻氛围',
|
bgPrompt: 'soft dreamy holographic background, lavender and pink layered gradient, gentle bubble bokeh and soft star sparkles, single diagonal sheen sweep at 135°, dreamy celestial mood, no text, no people, no watermark',
|
||||||
overlayPrompt: '精致银色星点,细边框装饰线,散落星形闪光和柔和光斑',
|
overlayPrompt: 'thin pearl border, soft star scatter, subtle corner brackets',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'classic',
|
id: 'classic',
|
||||||
@ -44,8 +44,8 @@ export const PRESET_VARIANTS = [
|
|||||||
frostIntensity: 6,
|
frostIntensity: 6,
|
||||||
flowSpeed: 0.45,
|
flowSpeed: 0.45,
|
||||||
colorHint: '香槟金 暖金色 浅米色',
|
colorHint: '香槟金 暖金色 浅米色',
|
||||||
bgPrompt: '经典复古胶片风格,暖色调,浅香槟色渐变,艺术纹理和柔和光影,优雅复古氛围',
|
bgPrompt: 'classic vintage refractor holographic surface, warm champagne and pale gold gradient, single diagonal sheen sweep at 120°, soft film grain texture, refined retro cardstock mood, no text, no people, no watermark',
|
||||||
overlayPrompt: '经典复古边框花纹,暖金色调和银色细线,精致几何图案',
|
overlayPrompt: 'warm sepia border, vintage corner motif',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'holoFull',
|
id: 'holoFull',
|
||||||
@ -61,8 +61,8 @@ export const PRESET_VARIANTS = [
|
|||||||
frostIntensity: 10,
|
frostIntensity: 10,
|
||||||
flowSpeed: 0.30,
|
flowSpeed: 0.30,
|
||||||
colorHint: '银色 霓虹色 金属银',
|
colorHint: '银色 霓虹色 金属银',
|
||||||
bgPrompt: '全息科技感渐变,银色金属质感,未来主义光影和几何线条,珍珠光泽,炫酷科技氛围',
|
bgPrompt: 'full holographic tech background, silver metallic base with pearl glow, geometric neon circuit lines and star sparks, vivid diagonal sheen sweep at 150°, high foil coverage 95%, futuristic cyber-card mood, no text, no people, no watermark',
|
||||||
overlayPrompt: '全息科技感装饰,霓虹色细线光晕,电路板几何图案和星形闪光',
|
overlayPrompt: 'neon bevel border, sharp diagonal sheen sweep, geometric corner accents',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ice',
|
id: 'ice',
|
||||||
@ -78,8 +78,8 @@ export const PRESET_VARIANTS = [
|
|||||||
frostIntensity: 5,
|
frostIntensity: 5,
|
||||||
flowSpeed: 0.50,
|
flowSpeed: 0.50,
|
||||||
colorHint: '浅蓝灰 冰蓝 银色',
|
colorHint: '浅蓝灰 冰蓝 银色',
|
||||||
bgPrompt: '冰雪冷色调渐变,浅蓝灰背景,冰晶纹理和雪花光斑,清爽透明感,纯净冰雪氛围',
|
bgPrompt: 'cool ice holographic background, pale blue-gray base with crystalline frost texture, snowflake scatter and ice-crack corner motif, fast diagonal sheen sweep at 110° (speed 0.50), crisp clean winter mood, no text, no people, no watermark',
|
||||||
overlayPrompt: '冰晶装饰,浅蓝色和银色星点,雪花图案和细线装饰',
|
overlayPrompt: 'thin frosted edge line, sparse ice flake scatter, light frost corner motif',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sunset',
|
id: 'sunset',
|
||||||
@ -95,8 +95,8 @@ export const PRESET_VARIANTS = [
|
|||||||
frostIntensity: 7,
|
frostIntensity: 7,
|
||||||
flowSpeed: 0.40,
|
flowSpeed: 0.40,
|
||||||
colorHint: '玫瑰金 暖橙色 粉色',
|
colorHint: '玫瑰金 暖橙色 粉色',
|
||||||
bgPrompt: '日落暖色调渐变,玫瑰金背景,暖橙色和粉色光影,温柔浪漫氛围',
|
bgPrompt: 'warm sunset holographic background, rose-gold base with amber and pink layered glow, soft floral corner accents, gentle diagonal sheen sweep at 140° (speed 0.40), romantic dreamy mood, no text, no people, no watermark',
|
||||||
overlayPrompt: '日落暖色调装饰,玫瑰金和琥珀色星点,柔和花卉和光线元素',
|
overlayPrompt: 'thin rose gold border, small heart corner accents',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -141,3 +141,55 @@ export function buildRenderConfigs(presetIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { resolveGratingConfig, DEFAULT_BACKDROP_TONE } from './laserGrating.js'
|
export { resolveGratingConfig, DEFAULT_BACKDROP_TONE } from './laserGrating.js'
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 新增:风格池 + 抽卡(45 个风格,5 档分层)
|
||||||
|
// - 旧调用方(激光导出会、success.vue)继续用上方 export,完全兼容
|
||||||
|
// - 新调用方(抽卡、稀有度徽章)使用下方 export
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// 风格池与索引(转自 stylePool.js)
|
||||||
|
export {
|
||||||
|
LASER_STYLE_POOL,
|
||||||
|
LASER_STYLE_IDS,
|
||||||
|
LASER_STYLE_INDEX,
|
||||||
|
RARITY_DISTRIBUTION,
|
||||||
|
RARITY_COLORS,
|
||||||
|
RARITY_STARS,
|
||||||
|
} from './stylePool.js'
|
||||||
|
|
||||||
|
// 抽卡算法(转自 gacha.js)
|
||||||
|
export {
|
||||||
|
drawGachaStyles,
|
||||||
|
drawGachaStyleIds,
|
||||||
|
pickRarity,
|
||||||
|
pickStyleInRarity,
|
||||||
|
findStyleById,
|
||||||
|
RARITY_WEIGHTS,
|
||||||
|
} from './gacha.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容旧调用方:按数字索引取 style 对象
|
||||||
|
* 0-4 仍映射到原 dream/classic/holoFull/ice/sunset(与旧 PRESET_VARIANTS 顺序一致)
|
||||||
|
* 5~44 映射到新池子
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
export function getStyleByIndex(index) {
|
||||||
|
const i = Number(index)
|
||||||
|
if (!Number.isFinite(i) || i < 0) return LASER_STYLE_POOL[0]
|
||||||
|
if (i < 5) {
|
||||||
|
const oldIds = ['dream', 'classic', 'holoFull', 'ice', 'sunset']
|
||||||
|
return LASER_STYLE_POOL.find((s) => s.id === oldIds[i]) || LASER_STYLE_POOL[0]
|
||||||
|
}
|
||||||
|
return LASER_STYLE_POOL[i] || LASER_STYLE_POOL[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容旧调用方:按 style id 反查数字索引(用于 success.vue)
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getStyleIndexById(id) {
|
||||||
|
const idx = LASER_STYLE_INDEX[String(id || '')]
|
||||||
|
return Number.isFinite(idx) ? idx : 0
|
||||||
|
}
|
||||||
|
|||||||
@ -70,6 +70,23 @@ class AiChatSocket extends SocketManager {
|
|||||||
* 连接到 AI Chat 服务
|
* 连接到 AI Chat 服务
|
||||||
*/
|
*/
|
||||||
async connect(token) {
|
async connect(token) {
|
||||||
|
// 注册 auth_fail 处理器:token 无效时清除本地凭证
|
||||||
|
this.off('auth_fail', this._authFailHandler)
|
||||||
|
this._authFailHandler = () => {
|
||||||
|
console.warn('[AiChatSocket] Auth failed, clearing token')
|
||||||
|
uni.removeStorageSync('access_token')
|
||||||
|
uni.removeStorageSync('user')
|
||||||
|
uni.showModal({
|
||||||
|
title: '登录已过期',
|
||||||
|
content: '请重新登录',
|
||||||
|
showCancel: false,
|
||||||
|
success: () => {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.on('auth_fail', this._authFailHandler)
|
||||||
|
|
||||||
await super.connect(token, AI_CHAT_WS_PATH)
|
await super.connect(token, AI_CHAT_WS_PATH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -75,6 +75,13 @@ class SocketManager {
|
|||||||
url,
|
url,
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
console.error(`[${this.serviceName}] connectSocket fail:`, err)
|
console.error(`[${this.serviceName}] connectSocket fail:`, err)
|
||||||
|
var errMsg = (err && (err.errMsg || '')).toLowerCase()
|
||||||
|
if (errMsg.indexOf('auth') !== -1 || errMsg.indexOf('reject') !== -1 || errMsg.indexOf('401') !== -1) {
|
||||||
|
console.warn(`[${this.serviceName}] Connection rejected (auth failure)`)
|
||||||
|
this._emit('auth_fail', { code: 'CONNECT_FAILED', message: err.errMsg || '连接失败' })
|
||||||
|
this.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
this._emit('error', { code: 'CONNECT_FAILED', message: err.errMsg || '连接失败' })
|
this._emit('error', { code: 'CONNECT_FAILED', message: err.errMsg || '连接失败' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -164,16 +171,22 @@ class SocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 连接错误
|
// 连接错误
|
||||||
|
var handleSocketError = function(err) {
|
||||||
|
console.error(`[${self.serviceName}] WebSocket error:`, err)
|
||||||
|
// 检查是否是鉴权相关的错误(401/403)
|
||||||
|
var errMsg = (err && (err.errMsg || err.message || '')).toLowerCase()
|
||||||
|
if (errMsg.indexOf('auth') !== -1 || errMsg.indexOf('reject') !== -1 || errMsg.indexOf('401') !== -1) {
|
||||||
|
console.warn(`[${self.serviceName}] Connection rejected (auth failure), clearing token`)
|
||||||
|
self._emit('auth_fail', err)
|
||||||
|
self.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self._emit('error', err)
|
||||||
|
}
|
||||||
if (typeof socket.onError === 'function') {
|
if (typeof socket.onError === 'function') {
|
||||||
socket.onError(function(err) {
|
socket.onError(handleSocketError)
|
||||||
console.error(`[${self.serviceName}] WebSocket error:`, err)
|
|
||||||
self._emit('error', err)
|
|
||||||
})
|
|
||||||
} else if (typeof socket.onerror === 'function') {
|
} else if (typeof socket.onerror === 'function') {
|
||||||
socket.onerror(function(err) {
|
socket.onerror(handleSocketError)
|
||||||
console.error(`[${self.serviceName}] WebSocket error:`, err)
|
|
||||||
self._emit('error', err)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import uni from '@dcloudio/vite-plugin-uni'
|
import { createRequire } from 'node:module'
|
||||||
|
// @dcloudio/vite-plugin-uni v3 alpha 的 dist 是 CJS,
|
||||||
|
// 且 __esModule 标志为非枚举,Node 的 CJS-to-ESM 互操作
|
||||||
|
// 不会把它当 TS 编译产物,于是 `import uni from ...` 拿到的
|
||||||
|
// 是整个 module.exports 对象而不是函数,调用就报
|
||||||
|
// "uni is not a function"。用 createRequire 直走 CJS 拿 default。
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const uni = require('@dcloudio/vite-plugin-uni').default
|
||||||
|
|
||||||
/** 与 upload-signature 返回的 OSS 虚拟域名一致;换桶时请同步 */
|
/** 与 upload-signature 返回的 OSS 虚拟域名一致;换桶时请同步 */
|
||||||
const OSS_DEV_HOST = 'top-fans-test.oss-cn-shanghai.aliyuncs.com'
|
const OSS_DEV_HOST = 'top-fans-test.oss-cn-shanghai.aliyuncs.com'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user