From af7908e72ee5220f2ec77791cf18ceca2feb53a8 Mon Sep 17 00:00:00 2001 From: Lenticular Studio Agent Date: Tue, 23 Jun 2026 22:43:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=BE=AE=E8=BE=BEAPI?= =?UTF-8?q?=E4=B8=AD=E8=BD=AC=E7=AB=99=EF=BC=8C=E9=87=8D=E6=9E=84=E9=95=AD?= =?UTF-8?q?=E5=B0=84=E5=8D=A1=E7=94=9F=E5=9B=BE=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换中转站从 xbcl.link 到 weda.cc - prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖) - 4 路并发调用 + 原图展示 = 5 张 variant - 前端提示词中译英支持 - 全局 Vue errorHandler - WebSocket 鉴权失败跳登录 - 删除已弃用的 laserCompositor 微服务 Co-Authored-By: Claude --- backend/.env.example | 21 +- backend/gateway/config/config.go | 37 +- .../controller/laser_generate_controller.go | 727 +++++++++++++++--- backend/gateway/router/router.go | 3 + backend/gateway/service/dify_client.go | 2 +- backend/gateway/service/openai_client.go | 321 ++++++++ backend/gateway/service/segment_service.go | 62 +- backend/go.work | 1 - docker/.env.local | 13 + docker/.env.local.dev | 52 ++ docker/.env.prod | 22 +- docker/Dockerfile.services | 16 - docker/docker-compose.local.yml | 37 - docker/docker-compose.prod.yml | 35 - docs/dify/laser_card_variant_v1.yml | 2 +- docs/dify/laser_card_variants_v1.yml | 425 ++++++---- docs/dify/laser_prompt_enhancer_v2.yml | 190 +++++ .../components/laser/LaserPreviewCanvas.vue | 48 ++ frontend/composables/useLaserBatchGenerate.js | 26 +- frontend/composables/useLaserDifyGenerate.js | 160 +++- frontend/composables/useLaserSegment.js | 20 +- frontend/main.js | 16 + frontend/manifest.json | 2 +- frontend/package.json | 8 + frontend/utils/laser-card/laserPresets.js | 72 +- frontend/utils/socket/AiChatSocket.js | 17 + frontend/utils/socket/SocketManager.js | 29 +- frontend/vite.config.js | 9 +- 28 files changed, 1923 insertions(+), 450 deletions(-) create mode 100644 backend/gateway/service/openai_client.go create mode 100644 docker/.env.local.dev create mode 100644 docs/dify/laser_prompt_enhancer_v2.yml diff --git a/backend/.env.example b/backend/.env.example index 2e579c4..8600dd4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -81,9 +81,26 @@ WS_AI_CHAT_PATH=/ai-chat # ==================== Dify AI Workflow ==================== # 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_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 内网地址 # 注意:7000 端口在 macOS 上被 AirPlay Receiver 占用,因此改用 7002 LASER_COMPOSITOR_URL=http://127.0.0.1:7002 diff --git a/backend/gateway/config/config.go b/backend/gateway/config/config.go index dcdc4ad..c92793e 100644 --- a/backend/gateway/config/config.go +++ b/backend/gateway/config/config.go @@ -15,6 +15,8 @@ type Config struct { Segment SegmentConfig Dify DifyConfig Minimax MinimaxConfig + OpenAI OpenAIConfig + LaserGen LaserGenConfig LaserCompositor LaserCompositorConfig Redis RedisConfig DB DBConfig @@ -22,6 +24,15 @@ type Config struct { 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 图像生成配置 type MinimaxConfig struct { APIKey string // MiniMax API 密钥 @@ -35,8 +46,17 @@ type LaserCompositorConfig struct { // DifyConfig Dify 工作流配置 type DifyConfig struct { - APIBase string - APIKey string + APIBase 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 配置 @@ -155,13 +175,22 @@ func Load() *Config { InferenceURL: getEnv("SEGMENT_INFERENCE_URL", ""), }, Dify: DifyConfig{ - APIBase: getEnv("DIFY_API_BASE", ""), - APIKey: getEnv("DIFY_API_KEY", ""), + APIBase: getEnv("DIFY_API_BASE", ""), + APIKey: getEnv("DIFY_API_KEY", ""), + Workflow: getEnv("DIFY_WORKFLOW", "laser_prompt_enhancer_v2"), }, Minimax: MinimaxConfig{ APIKey: getEnv("MINIMAX_API_KEY", ""), 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{ URL: getEnv("LASER_COMPOSITOR_URL", "http://127.0.0.1:7000"), }, diff --git a/backend/gateway/controller/laser_generate_controller.go b/backend/gateway/controller/laser_generate_controller.go index e16d063..a809005 100644 --- a/backend/gateway/controller/laser_generate_controller.go +++ b/backend/gateway/controller/laser_generate_controller.go @@ -1,9 +1,11 @@ package controller import ( + "bytes" "context" "encoding/json" "fmt" + "strings" "sync" "sync/atomic" "time" @@ -16,17 +18,31 @@ import ( "github.com/topfans/backend/gateway/pkg/response" "github.com/topfans/backend/gateway/repository" "github.com/topfans/backend/gateway/service" + "github.com/topfans/backend/gateway/service/compositor" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/logger" "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 生成控制器 type LaserGenerateController struct { minimaxClient *service.MinimaxClient - compositorClient *service.CompositorClient + difyClient *service.DifyClient + openaiClient *service.OpenAIClient jobStore *jobStore - laserRepo *repository.LaserCardRepository + laserRepo LaserCardPersister + ossHelper *service.OssHelper + provider string // "minimax" | "dify" | "openai" | "relay-dify",由 cfg.LaserGen.Provider 决定 } // jobStore 内存中维护 job 状态 @@ -58,11 +74,26 @@ var globalJobStore = &jobStore{ // NewLaserGenerateController 创建控制器 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{ - minimaxClient: service.NewMinimaxClient(cfg.Minimax.APIURL, cfg.Minimax.APIKey), - compositorClient: service.NewCompositorClient(cfg.LaserCompositor.URL), - jobStore: globalJobStore, - laserRepo: repository.NewLaserCardRepository(database.GetDB()), + minimaxClient: service.NewMinimaxClient(cfg.Minimax.APIURL, cfg.Minimax.APIKey), + difyClient: service.NewDifyClient(cfg.Dify.APIBase, cfg.Dify.APIKey), + openaiClient: service.NewOpenAIClient(cfg.OpenAI.BaseURL, cfg.OpenAI.APIKey, cfg.OpenAI.Model), + 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) } + // 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) jobID := uuid.New().String() @@ -117,43 +166,10 @@ func (ctrl *LaserGenerateController) CreateGenerateJob(c *gin.Context) { if len(req.PresetCodes) > 0 { templateCode = req.PresetCodes[0] } + // 创建 instance + 写创建日志(统一走 persistGeneratedInstance) var instanceID int64 var instanceNoResp string - if ctrl.laserRepo != nil { - 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), - ) - } - } + instanceID, instanceNoResp = ctrl.persistGeneratedInstance(userID.(int64), starID, templateCode, jobID) job := &GenerateJob{ ID: jobID, @@ -248,6 +264,396 @@ func parseVariants(req generateReq) []variantConfig { 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 合成 func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64, jobID, cutoutURL, userPrompt string, variants []variantConfig) { ctx := context.Background() @@ -335,38 +741,49 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64, zap.String("cutout_url_prefix", safePrefix(cutoutURL, 50)), zap.String("overlay_url_prefix", safePrefix(overlayURL, 50)), ) - compReq := service.ComposeRequest{ + compReq := compositor.ComposeRequest{ BackgroundURL: bgURL, CutoutURL: cutoutURL, OverlayURL: overlayURL, - GratingConfig: vc.GratingConfig, ExportWidth: 450, ExportHeight: 600, VariantIndex: idx, - OutputOSSKey: ossKey, } - compResp, err := ctrl.compositorClient.Compose(ctx, compReq) - if err != nil { + pngData, compErr := compositor.Compose(compReq) + if compErr != nil { logger.Logger.Error("Compositor failed", 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)) updateProgress(done) 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{}{ "preset_id": vc.PresetID, - "oss_key": compResp.OSSKey, - "signed_url": compResp.SignedURL, - "width": compResp.Width, - "height": compResp.Height, - } - if compResp.Warning != "" { - data["warning"] = compResp.Warning + "oss_key": compOSSKet, + "signed_url": compSignedURL, + "width": 450, + "height": 600, } resultCh <- variantResult{PresetID: vc.PresetID, Data: data} @@ -408,40 +825,9 @@ func (ctrl *LaserGenerateController) runParallelGeneration(userID, starID int64, } else { job.Status = "succeeded" job.Progress = 1.0 - // 持久化:更新 materials_snapshot - if ctrl.laserRepo != nil && job.InstanceID > 0 { - snapshot := make(models.MaterialsSnapshot, 0, len(job.Variants)+1) - // 用户原图/抠图记录 - 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, - "", - ) - } + // 持久化:更新 materials_snapshot(统一走 attachMaterialsSnapshot) + // MiniMax 异步路径:前端先抠图,job.CutoutURL 实际是抠过的人像 + ctrl.attachMaterialsSnapshot(job.InstanceID, job.InstanceNo, job.UserID, job.CutoutURL, "cutout", job.Variants, jobID) } 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 { if v == nil { return "" @@ -462,9 +861,159 @@ func marshalToString(v interface{}) string { 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 { if len(s) <= n { return s } 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 +} diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 9e754be..62dbc9d 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -11,6 +11,7 @@ import ( "github.com/topfans/backend/gateway/config" "github.com/topfans/backend/gateway/controller" "github.com/topfans/backend/gateway/middleware" + "github.com/topfans/backend/gateway/service" "github.com/topfans/backend/gateway/socket" ) @@ -104,6 +105,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl segmentCtrl := controller.NewSegmentController() laserGenCtrl := controller.NewLaserGenerateController(config.Load()) + composeCtrl := controller.NewComposeController(service.NewOssHelper(config.Load().OSS)) aiChatCtrl, err := controller.NewAIChatController(aiChatClient) if err != nil { return nil, err @@ -244,6 +246,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl { laser.POST("/generate", laserGenCtrl.CreateGenerateJob) laser.GET("/generate/:id", laserGenCtrl.GetGenerateJob) + laser.POST("/compose", composeCtrl.ComposeSingle) } // 资产相关路由(需要认证) diff --git a/backend/gateway/service/dify_client.go b/backend/gateway/service/dify_client.go index 000407a..eae0899 100644 --- a/backend/gateway/service/dify_client.go +++ b/backend/gateway/service/dify_client.go @@ -26,7 +26,7 @@ func NewDifyClient(baseURL, apiKey string) *DifyClient { BaseURL: baseURL, APIKey: apiKey, HTTPClient: &http.Client{ - Timeout: 120 * time.Second, // Dify 工作流最长等待 120s + Timeout: 600 * time.Second, // Dify 工作流(MiniMax 文生图 + 合成), 最长约 300s, 留 300s 余量 }, } } diff --git a/backend/gateway/service/openai_client.go b/backend/gateway/service/openai_client.go new file mode 100644 index 0000000..8d217a7 --- /dev/null +++ b/backend/gateway/service/openai_client.go @@ -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" +} diff --git a/backend/gateway/service/segment_service.go b/backend/gateway/service/segment_service.go index 1e24db0..59eae3c 100644 --- a/backend/gateway/service/segment_service.go +++ b/backend/gateway/service/segment_service.go @@ -24,13 +24,19 @@ const ( func MaxSegmentImageBytes() int { return maxSegmentImageBytes } // SegmentPortraitResult 人像抠图结果 +// +// 字段语义: +// - CutoutOssKey/URLSigned: 抠过的透明人像 PNG(给 compositor 6 层合成人像层用) +// - OriginalOssKey/URLSigned: 用户上传的原图(给 OpenAI 直接喂原图用,不再二次抠图) type SegmentPortraitResult struct { - Success bool `json:"success"` - ErrorCode string `json:"error_code,omitempty"` - Message string `json:"message,omitempty"` - CutoutOssKey string `json:"cutout_oss_key,omitempty"` - CutoutURLSigned string `json:"cutout_url_signed,omitempty"` - Provider string `json:"provider,omitempty"` + Success bool `json:"success"` + ErrorCode string `json:"error_code,omitempty"` + Message string `json:"message,omitempty"` + CutoutOssKey string `json:"cutout_oss_key,omitempty"` + CutoutURLSigned string `json:"cutout_url_signed,omitempty"` + OriginalOssKey string `json:"original_oss_key,omitempty"` + OriginalURLSigned string `json:"original_url_signed,omitempty"` + Provider string `json:"provider,omitempty"` } // 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) if err != nil { 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{ - Success: false, - ErrorCode: SegmentErrorCodeFailed, - Message: err.Error(), + Success: false, + ErrorCode: SegmentErrorCodeFailed, + Message: "抠图失败: " + err.Error(), + OriginalOssKey: inKey, + OriginalURLSigned: inSigned, }, nil } @@ -155,24 +166,30 @@ func (s *SegmentService) doPortrait(ctx context.Context, starID, userID int64, f outSigned, err := s.oss.SignGetURL(outKey, 3600) if err != nil { return &SegmentPortraitResult{ - Success: true, - CutoutOssKey: outKey, - Provider: cutout.Provider, - Message: "抠图成功但签名 URL 生成失败", + Success: true, + CutoutOssKey: outKey, + OriginalOssKey: cutout.InKey, + OriginalURLSigned: cutout.InURLSigned, + Provider: cutout.Provider, + Message: "抠图成功但签名 URL 生成失败", }, nil } return &SegmentPortraitResult{ - Success: true, - CutoutOssKey: outKey, - CutoutURLSigned: outSigned, - Provider: cutout.Provider, + Success: true, + CutoutOssKey: outKey, + CutoutURLSigned: outSigned, + OriginalOssKey: cutout.InKey, + OriginalURLSigned: cutout.InURLSigned, + Provider: cutout.Provider, }, nil } type cutoutInferResult struct { - Bytes []byte - Provider string + Bytes []byte + 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) { @@ -203,7 +220,7 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64, if err != nil { 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) { @@ -218,7 +235,7 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64, if err != nil { 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) { @@ -226,7 +243,8 @@ func (s *SegmentService) inferCutout(ctx context.Context, starID, userID int64, if err != nil { 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 diff --git a/backend/go.work b/backend/go.work index 15dc81a..501720e 100644 --- a/backend/go.work +++ b/backend/go.work @@ -7,7 +7,6 @@ use ( ./services/aiChatService ./services/assetService ./services/galleryService - ./services/laserCompositor ./services/socialService ./services/statisticService ./services/taskService diff --git a/docker/.env.local b/docker/.env.local index bc158f2..a40d907 100644 --- a/docker/.env.local +++ b/docker/.env.local @@ -23,3 +23,16 @@ OSS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD OSS_AVATAR_DIR=avatar/ OSS_ASSET_DIR=asset/ 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= diff --git a/docker/.env.local.dev b/docker/.env.local.dev new file mode 100644 index 0000000..efc7e33 --- /dev/null +++ b/docker/.env.local.dev @@ -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 diff --git a/docker/.env.prod b/docker/.env.prod index 5e7ecf6..dcba348 100644 --- a/docker/.env.prod +++ b/docker/.env.prod @@ -22,7 +22,7 @@ OSS_ASSET_DIR=asset/ OSS_TOKEN_EXPIRE_TIME=3600 # ==================== 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 # Redis Configuration @@ -31,11 +31,23 @@ REDIS_PORT=6379 REDIS_PASSWORD=123456 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) ==================== -# 注意:当前 laser 卡生成走的是 gateway 内的 MiniMax 直接调用(不是 Dify), -# 这两个变量仅为 Dify 工作流调用保留,目前未使用 -DIFY_API_BASE=https://api.dify.ai/v1 -DIFY_API_KEY= +# Dify API 入口 (laser_card_variants_v1 工作流, 仅在 LASER_GEN_PROVIDER=dify 时使用) +DIFY_API_BASE=http://localhost/v1 +DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA # ==================== Laser Card ==================== # 镭射卡 6 层合成服务(gateway 通过容器名 + 端口访问) diff --git a/docker/Dockerfile.services b/docker/Dockerfile.services index 7f1a727..c77bb00 100644 --- a/docker/Dockerfile.services +++ b/docker/Dockerfile.services @@ -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 && \ echo "Built aichatservice" && \ 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 && \ echo "Built statisticservice" @@ -196,20 +193,7 @@ HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \ 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 (数据看板微服务) ---- FROM --platform=linux/amd64 alpine:3.19 AS statisticservice diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml index 1d7125e..f1b21a1 100644 --- a/docker/docker-compose.local.yml +++ b/docker/docker-compose.local.yml @@ -298,40 +298,6 @@ services: reservations: 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 (数据看板微服务) ==================== statisticservice: @@ -405,7 +371,6 @@ services: MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} MINIMAX_API_URL: ${MINIMAX_API_URL:-https://api.minimaxi.com/v1/image_generation} # 镭射卡 6 层合成微服务(容器内通过 service name 访问) - LASER_COMPOSITOR_URL: http://lasercompositor:7002 # 抠图(人像扣底) SEGMENT_PROVIDER: ${SEGMENT_PROVIDER:-imageseg} # OSS 配置(gateway 用于签名 + 抠图上传) @@ -436,8 +401,6 @@ services: condition: service_healthy statisticservice: condition: service_healthy - lasercompositor: - condition: service_healthy networks: topfans-net: aliases: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index e26e2e4..e1ccb51 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -429,39 +429,6 @@ services: memory: 256M 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 (数据看板微服务) ==================== statisticservice: image: topfans/statisticservice:latest @@ -535,7 +502,6 @@ services: DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20005 DUBBO_AI_CHAT_SERVICE_URL: tri://aichatservice:20008 DUBBO_STATISTIC_SERVICE_URL: tri://statisticservice:20009 - LASER_COMPOSITOR_URL: http://lasercompositor:7002 # 抠图(人像扣底)、OSS、Dify、JWT、Redis 全部走 env_file: .env.prod REDIS_HOST: topfans-redis REDIS_PORT: 6379 @@ -559,7 +525,6 @@ services: condition: service_started statisticservice: condition: service_started - lasercompositor: condition: service_started redis: condition: service_healthy diff --git a/docs/dify/laser_card_variant_v1.yml b/docs/dify/laser_card_variant_v1.yml index 63a4f79..2b26e93 100644 --- a/docs/dify/laser_card_variant_v1.yml +++ b/docs/dify/laser_card_variant_v1.yml @@ -225,7 +225,7 @@ workflow: max_write_timeout: 20 title: 合成镭射卡 type: http-request - url: '{{#env.LASER_COMPOSITOR_HOST#}}/compose' + url: '{{#env.LASER_COMPOSITOR_HOST#}}' variables: [] height: 93 id: compositor diff --git a/docs/dify/laser_card_variants_v1.yml b/docs/dify/laser_card_variants_v1.yml index 5d98d9c..82fbd31 100644 --- a/docs/dify/laser_card_variants_v1.yml +++ b/docs/dify/laser_card_variants_v1.yml @@ -6,18 +6,18 @@ app: name: laser_card_variants_v1 use_icon_as_answer_icon: false kind: app -version: 0.1.4 +version: 0.1.6 workflow: conversation_variables: [] environment_variables: - description: 'laser-compositor 合成服务地址' name: LASER_COMPOSITOR_HOST - value: 'http://host.docker.internal:7000' + value: 'http://host.docker.internal:18090/api/v1/laser' value_type: string - description: 'MiniMax API 密钥' name: MINIMAX_API_KEY - value: '' - value_type: secret + value: 'sk-api-oezuuNMr5iwPdlJ1JgTJTSzhMhGtaUR5Odjjg0ZqVQ7MoMIqLuE_ginMWRkNiAiDgMY6MvTVkYCWSQ8SK1-LuldrFmohCHxCgZIbxsFYr9zxA8z08Eb8nbo' + value_type: string features: file_upload: image: @@ -42,7 +42,7 @@ workflow: voice: '' graph: edges: - # start → 参数展开 + # start → code-param - data: isInIteration: false isInLoop: false @@ -55,20 +55,85 @@ workflow: targetHandle: target type: custom 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: isInIteration: false isInLoop: false sourceType: code targetType: iteration - id: code-param-source-loop-variants-target - source: code-param + id: code-prepare-source-loop-variants-target + source: code-prepare sourceHandle: source target: loop-variants targetHandle: target type: custom zIndex: 0 - # 循环 → 聚合 + # loop-variants → code-agg - data: isInIteration: false isInLoop: false @@ -81,7 +146,7 @@ workflow: targetHandle: target type: custom zIndex: 0 - # 聚合 → 结束 + # code-agg → end - data: isInIteration: false isInLoop: false @@ -95,45 +160,17 @@ workflow: type: custom zIndex: 0 # === 循环内部 === - # iter-start → MiniMax 背景 + # iter-start → code-call-compositor(直接合成,bg/overlay 已预生成) - data: isInIteration: true iteration_id: loop-variants isInLoop: false sourceType: start - targetType: http-request - id: iter-start-source-http-bg-target + targetType: code + id: iter-start-source-code-call-compositor-target source: iter-start sourceHandle: source - target: http-bg - 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 + target: code-call-compositor targetHandle: target type: custom zIndex: 1000 @@ -180,13 +217,13 @@ workflow: type: custom width: 244 # ================================================================ - # 代码节点 — 参数展开 + # 代码节点 — 参数展开(同时输出合并的 prompt 字符串) # ================================================================ - 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 dependencies: [] - desc: '解析 JSON 字符串 → 构建 variants 列表' + desc: '解析 JSON 字符串 → 构建 variants 列表 + 合并 prompt' outputs: variant_count: children: null @@ -194,6 +231,12 @@ workflow: variants: children: null type: array[object] + bg_prompts_all: + children: null + type: string + overlay_prompts_all: + children: null + type: string selected: false title: 参数展开 type: code @@ -206,7 +249,7 @@ workflow: - start - render_configs variable: render_configs - height: 84 + height: 120 id: code-param position: x: 320 @@ -220,34 +263,161 @@ workflow: type: custom width: 244 # ================================================================ - # 循环节点 + # 前置 HTTP — 一次生成 5 张背景图 (n=5) # ================================================================ - 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 height: 351 iterator_selector: - - code-param - - variants - is_parallel: false + - code-prepare + - enriched_variants + is_parallel: true output_selector: - - http-compositor - - body + - code-call-compositor + - compose_result output_type: array[string] - parallel_nums: 1 + parallel_nums: 5 selected: false startNodeType: start start_node_id: iter-start - title: 遍历 Variants + title: 遍历 Variants(并行5) type: iteration width: 953 height: 351 id: loop-variants position: - x: 610 + x: 1200 y: 300 positionAbsolute: - x: 610 + x: 1200 y: 300 selected: false sourcePosition: right @@ -259,7 +429,7 @@ workflow: # 循环内部 — 开始 # ================================================================ - data: - desc: 当前 variant_item + desc: 当前 variant_item(含预生成的 bg_url / overlay_url) isInIteration: true iteration_id: loop-variants selected: false @@ -274,7 +444,7 @@ workflow: x: 87 y: 120 positionAbsolute: - x: 697 + x: 1287 y: 420 selected: false sourcePosition: right @@ -283,122 +453,45 @@ workflow: width: 244 zIndex: 1001 # ================================================================ - # 循环内部 — MiniMax 背景 + # 循环内部 — Code 节点:调 compositor 合成 + # 直接从 enriched item 读取 bg_url / overlay_url,不再调 MiniMax # ================================================================ - data: - authorization: - config: - api_key: '{{#env.MINIMAX_API_KEY#}}' - type: bearer-api-key - body: - data: '{"model": "image-01", "prompt": "{{#loop-variants.item.bg_prompt#}}", "aspect_ratio": "3:4", "n": 1, "prompt_optimizer": true, "response_format": "url"}' - type: json - desc: '根据 bg_prompt 生成 3:4 背景' - headers: 'Content-Type: application/json' - isInIteration: true - iteration_id: loop-variants - method: post - params: '' + 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" + code_language: python3 + dependencies: + - name: requests + version: 2.32.3 + desc: '用预生成的 bg_url/overlay_url → 调 compositor 合成 → 注入 preset_id' + outputs: + compose_result: + children: null + type: string 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: [] + title: 调 compositor 合成(批量优化版) + type: code + variables: + - value_selector: + - start + - cutout_url + variable: cutout_url + - value_selector: + - loop-variants + - item + variable: item + - value_selector: + - env + - LASER_COMPOSITOR_HOST + variable: compositor_host extent: parent height: 93 - id: http-bg - 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 + id: code-call-compositor parentId: loop-variants position: x: 957 y: 120 positionAbsolute: - x: 1567 + x: 1767 y: 420 selected: false sourcePosition: right @@ -410,7 +503,7 @@ workflow: # 代码节点 — 聚合输出 # ================================================================ - 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 dependencies: [] desc: '收集循环结果,输出统一 JSON' diff --git a/docs/dify/laser_prompt_enhancer_v2.yml b/docs/dify/laser_prompt_enhancer_v2.yml new file mode 100644 index 0000000..bd8f286 --- /dev/null +++ b/docs/dify/laser_prompt_enhancer_v2.yml @@ -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 diff --git a/frontend/components/laser/LaserPreviewCanvas.vue b/frontend/components/laser/LaserPreviewCanvas.vue index e69cccc..83c5cf0 100644 --- a/frontend/components/laser/LaserPreviewCanvas.vue +++ b/frontend/components/laser/LaserPreviewCanvas.vue @@ -18,6 +18,9 @@ mode="aspectFill" /> 无预览 + + + @@ -289,6 +292,7 @@ onBeforeUnmount(() => { } .laser-preview-fallback { + position: relative; width: 360px; height: 480px; border-radius: 24rpx; @@ -308,5 +312,49 @@ onBeforeUnmount(() => { color: rgba(255, 255, 255, 0.7); 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%; + } +} diff --git a/frontend/composables/useLaserBatchGenerate.js b/frontend/composables/useLaserBatchGenerate.js index 237f709..575f6f8 100644 --- a/frontend/composables/useLaserBatchGenerate.js +++ b/frontend/composables/useLaserBatchGenerate.js @@ -107,21 +107,29 @@ export function useLaserBatchGenerate(options = {}) { const imagePath = resolveImagePath(formData) // Dify 模式:服务端 AI 生成 - const genMode ='dify' - if (genMode === 'dify') { + // genMode: 'dify' | 'openai' | 'client' + // - 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 userPrompt = (runOptions.userPrompt || formData?.userPrompt || '').trim() - // 先做人像抠图,拿到 PNG 的 OSS 签名地址传给 compositor 叠加 - let cutoutUrl = '' + // 表单实际存的字段是 aiDescription;为兼容旧调用方(runOptions.userPrompt / formData.userPrompt)同时 fallback + const userPrompt = (runOptions.userPrompt || formData?.aiDescription || formData?.userPrompt || '').trim() + + // 上传到 OSS 获取可访问 URL,供中转站下载原图 + let imageUrlForAI = imagePath try { - const cutout = await segmentPortrait(imagePath, { + const uploadResult = await segmentPortrait(imagePath, { imageBase64: formData.imageBase64 || formData.uploadedImageBase64 || '', }) - cutoutUrl = cutout?.cutoutUrl || '' + imageUrlForAI = uploadResult?.originalUrl || uploadResult?.cutoutUrl || imagePath } 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 instanceNoVal = dify.getInstanceNo() await waitMinDuration(minMs) diff --git a/frontend/composables/useLaserDifyGenerate.js b/frontend/composables/useLaserDifyGenerate.js index 0d73a9c..9ce2b69 100644 --- a/frontend/composables/useLaserDifyGenerate.js +++ b/frontend/composables/useLaserDifyGenerate.js @@ -1,14 +1,18 @@ /** - * Dify 镭射五图生成 composable - * 通过 Gateway → Dify 工作流服务端生成镭射卡,替换客户端 Canvas 合成 + * 镭射五图生成 composable + * 兼容两种后端提供方: + * - Dify (LASER_GEN_PROVIDER=dify): 单次 blocking POST, 直接返回 variants + * - MiniMax (LASER_GEN_PROVIDER=minimax): 异步, POST 后轮询 * - * 模式切换:通过 VITE_LASER_GEN_MODE 环境变量控制 - * 'client' — 现有的客户端 laserBatchExport 路径 - * 'dify' — 本模块,Gateway 触发 Dify 工作流 + * 选择方式: 根据 POST 响应结构自动判断 + * - 含 job_id → 走轮询 (MiniMax 路径) + * - 含 variants → 走阻塞等待 (Dify 路径) */ import { ref } from 'vue' 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) @@ -30,7 +34,7 @@ async function laserRequest(opts) { method: opts.method || 'GET', data: opts.data || {}, header: headers, - timeout: 60000, + timeout: opts.timeout || DEFAULT_TIMEOUT_MS, success: (res) => { if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(res.data?.message || `HTTP ${res.statusCode}`)) @@ -60,8 +64,62 @@ export function useLaserDifyGenerate() { let _resolveSubmit = 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>} + */ + 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 + * 自动适配 Dify 阻塞 / MiniMax 异步两种模式 + * * @param {string} cutoutUrl 抠图后的 OSS URL(空串表示不叠加人像) * @param {string[]} [presetIds] 需要生成的 preset ID 列表 * @param {string} [userPrompt] 用户自定义 prompt,注入到 AI 生图 @@ -71,35 +129,56 @@ export function useLaserDifyGenerate() { error.value = null 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) try { + // Dify 阻塞模式单次请求可能耗时 30-120s, 先用较长 timeout const res = await laserRequest({ url: '/api/v1/laser/generate', method: 'POST', + timeout: DIFY_BLOCKING_TIMEOUT_MS, data: { cutout_url: cutoutUrl, preset_codes: presetCodes, render_configs: renderConfigs, - user_prompt: userPrompt || '', + // userPrompt 已经包装进 render_configs.bg_prompt,这里传空避免后端二次拼接 + user_prompt: '', }, }) - if (!res.data || !res.data.job_id) { - throw new Error('生成任务创建失败:未返回 job_id') + if (!res || !res.data) { + throw new Error('生成任务失败: 响应为空') } - jobId.value = res.data.job_id - status.value = 'processing' - pollingSince = Date.now() + const payload = res.data - // 等待轮询完成再 resolve - await new Promise((resolve, reject) => { - _resolveSubmit = resolve - _rejectSubmit = reject - startPolling() - }) + // 模式 1: Dify 阻塞 — 响应直接含 variants + if (payload.status === 'succeeded' && Array.isArray(payload.variants)) { + applySucceeded(payload, cutoutUrl) + return + } + + // 模式 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) { status.value = 'error' 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() { const pollInterval = 2000 // 每 2 秒轮询 const maxDuration = 120000 // 最长等待 120 秒 @@ -116,7 +215,7 @@ export function useLaserDifyGenerate() { // 超时降级 if (Date.now() - pollingSince > maxDuration) { - const err = new Error('Dify 生成超时,已降级到本地合成') + const err = new Error('生成超时, 已降级到本地合成') status.value = 'timeout' error.value = err stopPolling() @@ -129,7 +228,7 @@ export function useLaserDifyGenerate() { method: 'GET', }) .then((res) => { - if (!res.data) return + if (!res || !res.data) return // 更新进度 if (res.data.progress != null) { @@ -137,22 +236,11 @@ export function useLaserDifyGenerate() { } if (res.data.status === 'succeeded') { - // 提取 variants - 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 + applySucceeded(res.data, '') stopPolling() if (_resolveSubmit) { _resolveSubmit(); _resolveSubmit = null } } else if (res.data.status === 'failed') { - const err = new Error(res.data.error || 'Dify 工作流执行失败') + const err = new Error(res.data.error || '生成任务失败') status.value = 'error' error.value = err 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 }>} */ function getVariantInfos() { diff --git a/frontend/composables/useLaserSegment.js b/frontend/composables/useLaserSegment.js index b03a28d..de703d4 100644 --- a/frontend/composables/useLaserSegment.js +++ b/frontend/composables/useLaserSegment.js @@ -84,16 +84,24 @@ export async function segmentPortrait(imagePath, options = {}) { imageBase64: options.imageBase64 || '', }) const data = res?.data - if (!data?.success || !data?.cutout_url_signed) { - const msg = data?.message || data?.error_code || 'LC_SEGMENT_FAILED' - throw new Error(msg) + + // 只要原图上传到 OSS 就算成功,抠图失败不影响(OpenAI 模式不需要抠图) + // 只有连原图都没上传成功才抛错 + 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 { localPath, ossKey: data.cutout_oss_key || '', - cutoutUrl: data.cutout_url_signed || '', // OSS 签名的抠图 PNG 地址,供 Dify 使用 + cutoutUrl: data.cutout_url_signed || '', + originalUrl: data.original_url_signed || '', } } diff --git a/frontend/main.js b/frontend/main.js index 579a0cb..1d32e31 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -4,6 +4,14 @@ import App from './App' import Vue from 'vue' import './uni.promisify.adaptor' 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' const app = new Vue({ ...App @@ -16,6 +24,14 @@ import { createSSRApp } from 'vue' import store from './store' export function createApp() { 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) return { app diff --git a/frontend/manifest.json b/frontend/manifest.json index ec8d237..6dda88e 100644 --- a/frontend/manifest.json +++ b/frontend/manifest.json @@ -1,6 +1,6 @@ { "name" : "TopFans", - "appid" : "__UNI__F199FF4", + "appid" : "__UNI__8CBE431", "description" : "", "versionName" : "1.0.5", "versionCode" : 114, diff --git a/frontend/package.json b/frontend/package.json index 2c578ff..246bb56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,13 @@ { + "type": "module", "dependencies": { "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" } } diff --git a/frontend/utils/laser-card/laserPresets.js b/frontend/utils/laser-card/laserPresets.js index 08db32f..7b088ab 100644 --- a/frontend/utils/laser-card/laserPresets.js +++ b/frontend/utils/laser-card/laserPresets.js @@ -27,8 +27,8 @@ export const PRESET_VARIANTS = [ frostIntensity: 8, flowSpeed: 0.35, colorHint: '淡紫色 粉色 银色', - bgPrompt: '梦幻柔光渐变,淡紫色调和粉色,柔和泡泡和星光光斑,虚化景深,纯净梦幻氛围', - overlayPrompt: '精致银色星点,细边框装饰线,散落星形闪光和柔和光斑', + 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: 'thin pearl border, soft star scatter, subtle corner brackets', }, { id: 'classic', @@ -44,8 +44,8 @@ export const PRESET_VARIANTS = [ frostIntensity: 6, flowSpeed: 0.45, colorHint: '香槟金 暖金色 浅米色', - bgPrompt: '经典复古胶片风格,暖色调,浅香槟色渐变,艺术纹理和柔和光影,优雅复古氛围', - overlayPrompt: '经典复古边框花纹,暖金色调和银色细线,精致几何图案', + 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: 'warm sepia border, vintage corner motif', }, { id: 'holoFull', @@ -61,8 +61,8 @@ export const PRESET_VARIANTS = [ frostIntensity: 10, flowSpeed: 0.30, colorHint: '银色 霓虹色 金属银', - bgPrompt: '全息科技感渐变,银色金属质感,未来主义光影和几何线条,珍珠光泽,炫酷科技氛围', - overlayPrompt: '全息科技感装饰,霓虹色细线光晕,电路板几何图案和星形闪光', + 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: 'neon bevel border, sharp diagonal sheen sweep, geometric corner accents', }, { id: 'ice', @@ -78,8 +78,8 @@ export const PRESET_VARIANTS = [ frostIntensity: 5, flowSpeed: 0.50, colorHint: '浅蓝灰 冰蓝 银色', - bgPrompt: '冰雪冷色调渐变,浅蓝灰背景,冰晶纹理和雪花光斑,清爽透明感,纯净冰雪氛围', - overlayPrompt: '冰晶装饰,浅蓝色和银色星点,雪花图案和细线装饰', + 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: 'thin frosted edge line, sparse ice flake scatter, light frost corner motif', }, { id: 'sunset', @@ -95,8 +95,8 @@ export const PRESET_VARIANTS = [ frostIntensity: 7, flowSpeed: 0.40, colorHint: '玫瑰金 暖橙色 粉色', - bgPrompt: '日落暖色调渐变,玫瑰金背景,暖橙色和粉色光影,温柔浪漫氛围', - overlayPrompt: '日落暖色调装饰,玫瑰金和琥珀色星点,柔和花卉和光线元素', + 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: 'thin rose gold border, small heart corner accents', }, ] @@ -141,3 +141,55 @@ export function buildRenderConfigs(presetIds) { } 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 +} diff --git a/frontend/utils/socket/AiChatSocket.js b/frontend/utils/socket/AiChatSocket.js index fa081fd..ea3fac6 100644 --- a/frontend/utils/socket/AiChatSocket.js +++ b/frontend/utils/socket/AiChatSocket.js @@ -70,6 +70,23 @@ class AiChatSocket extends SocketManager { * 连接到 AI Chat 服务 */ 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) } diff --git a/frontend/utils/socket/SocketManager.js b/frontend/utils/socket/SocketManager.js index 614485d..3b910ee 100644 --- a/frontend/utils/socket/SocketManager.js +++ b/frontend/utils/socket/SocketManager.js @@ -75,6 +75,13 @@ class SocketManager { url, 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 || '连接失败' }) } }) @@ -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') { - socket.onError(function(err) { - console.error(`[${self.serviceName}] WebSocket error:`, err) - self._emit('error', err) - }) + socket.onError(handleSocketError) } else if (typeof socket.onerror === 'function') { - socket.onerror(function(err) { - console.error(`[${self.serviceName}] WebSocket error:`, err) - self._emit('error', err) - }) + socket.onerror(handleSocketError) } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 89d2202..aa287b5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,6 +1,13 @@ import { defineConfig } from 'vite' 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 虚拟域名一致;换桶时请同步 */ const OSS_DEV_HOST = 'top-fans-test.oss-cn-shanghai.aliyuncs.com'