From d9d3b51262ed16ec1f40c7be1a64bc3e9e732e1e Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 12:36:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(inference):=20=E9=9B=86=E6=88=90=20GPT=20I?= =?UTF-8?q?mage=202=20=E5=BC=82=E6=AD=A5=E6=96=87=E7=94=9F=E5=9B=BE=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GptImage2Config 配置(yuntts 等 GPT Image 2 兼容服务) - 新增 gptimage.go 异步客户端:提交任务 → 轮询状态 → 下载图片 - GenerateImages 优先级调整为:GPT Image 2 > OpenAI 兼容 ImageGen > Mock - 支持环境变量 GEN2D_GPT_IMAGE2_* 系列配置 --- backend/.env.example | 11 +- backend/cmd/main.go | 1 + backend/internal/config/config.go | 37 +++- backend/internal/service/gptimage.go | 268 ++++++++++++++++++++++++++ backend/internal/service/inference.go | 17 +- 5 files changed, 325 insertions(+), 9 deletions(-) mode change 100644 => 100755 backend/.env.example mode change 100644 => 100755 backend/internal/config/config.go create mode 100755 backend/internal/service/gptimage.go mode change 100644 => 100755 backend/internal/service/inference.go diff --git a/backend/.env.example b/backend/.env.example old mode 100644 new mode 100755 index 97bf0b8..2501aa8 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,7 +19,7 @@ GEN2D_LLM_MODEL=gpt-4o GEN2D_LLM_TEMPERATURE=0.7 GEN2D_LLM_MAX_TOKENS=2048 -# 文生图模型 +# 文生图模型(OpenAI 兼容同步 API) GEN2D_IMAGE_BASE_URL=https://api.stability.ai/v1 GEN2D_IMAGE_API_KEY=sk-your-api-key GEN2D_IMAGE_MODEL=stable-diffusion-xl @@ -28,3 +28,12 @@ GEN2D_IMAGE_HEIGHT=1024 GEN2D_IMAGE_NUM_IMAGES=1 GEN2D_IMAGE_STEPS=30 GEN2D_IMAGE_CFG_SCALE=7.0 + +# GPT Image 2 文生图(异步 API,如 yuntts 等兼容服务) +# 优先级高于 IMAGE API,留空则使用上方的 IMAGE API +GEN2D_GPT_IMAGE2_BASE_URL=https://www.yuntts.com/api/v1 +GEN2D_GPT_IMAGE2_API_KEY= +GEN2D_GPT_IMAGE2_ASPECT_RATIO=1:1 +GEN2D_GPT_IMAGE2_X_CHANNEL=default +GEN2D_GPT_IMAGE2_POLL_MAX_WAIT=120 +GEN2D_GPT_IMAGE2_POLL_INTERVAL=3 diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 2a447dd..af7578a 100755 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -35,6 +35,7 @@ func main() { // 注入 LLM 和文生图配置到 service 层 service.InitLLMConfig(cfg.LLM) service.InitImageGenConfig(cfg.ImageGen) + service.InitGptImage2Config(cfg.GptImage2) r := gin.New() r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go old mode 100644 new mode 100755 index 62359f3..12dea7e --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,11 +9,12 @@ import ( // Config 应用全局配置。 type Config struct { - Server ServerConfig `mapstructure:"server"` - Database DatabaseConfig `mapstructure:"database"` - JWT JWTConfig `mapstructure:"jwt"` - LLM LLMConfig `mapstructure:"llm"` - ImageGen ImageGenConfig `mapstructure:"image_gen"` + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + JWT JWTConfig `mapstructure:"jwt"` + LLM LLMConfig `mapstructure:"llm"` + ImageGen ImageGenConfig `mapstructure:"image_gen"` + GptImage2 GptImage2Config `mapstructure:"gpt_image2"` } // ServerConfig HTTP 服务配置。 @@ -43,7 +44,7 @@ type LLMConfig struct { MaxTokens int `mapstructure:"max_tokens"` } -// ImageGenConfig 文生图模型配置。 +// ImageGenConfig 文生图模型配置(OpenAI 兼容同步 API)。 type ImageGenConfig struct { BaseURL string `mapstructure:"base_url"` APIKey string `mapstructure:"api_key"` @@ -55,6 +56,16 @@ type ImageGenConfig struct { CFGScale float64 `mapstructure:"cfg_scale"` } +// GptImage2Config GPT Image 2 异步生图模型配置(yuntts 等兼容服务)。 +type GptImage2Config struct { + BaseURL string `mapstructure:"base_url"` + APIKey string `mapstructure:"api_key"` + AspectRatio string `mapstructure:"aspect_ratio"` + XChannel string `mapstructure:"x_channel"` + PollMaxWait int `mapstructure:"poll_max_wait"` // 轮询最大等待秒数, 默认 120 + PollInterval int `mapstructure:"poll_interval"` // 轮询间隔秒数, 默认 3 +} + // Load 从 YAML 配置文件和环境变量加载配置。 // 优先级:环境变量 > YAML 文件 > 默认值。 func Load() *Config { @@ -111,6 +122,13 @@ func setDefaults(v *viper.Viper) { v.SetDefault("image_gen.num_images", 1) v.SetDefault("image_gen.steps", 30) v.SetDefault("image_gen.cfg_scale", 7.0) + + v.SetDefault("gpt_image2.base_url", "https://www.yuntts.com/api/v1") + v.SetDefault("gpt_image2.api_key", "") + v.SetDefault("gpt_image2.aspect_ratio", "1:1") + v.SetDefault("gpt_image2.x_channel", "default") + v.SetDefault("gpt_image2.poll_max_wait", 120) + v.SetDefault("gpt_image2.poll_interval", 3) } func bindEnvVars(v *viper.Viper) { @@ -135,4 +153,11 @@ func bindEnvVars(v *viper.Viper) { v.BindEnv("image_gen.num_images", "GEN2D_IMAGE_NUM_IMAGES") v.BindEnv("image_gen.steps", "GEN2D_IMAGE_STEPS") v.BindEnv("image_gen.cfg_scale", "GEN2D_IMAGE_CFG_SCALE") + + v.BindEnv("gpt_image2.base_url", "GEN2D_GPT_IMAGE2_BASE_URL") + v.BindEnv("gpt_image2.api_key", "GEN2D_GPT_IMAGE2_API_KEY") + v.BindEnv("gpt_image2.aspect_ratio", "GEN2D_GPT_IMAGE2_ASPECT_RATIO") + v.BindEnv("gpt_image2.x_channel", "GEN2D_GPT_IMAGE2_X_CHANNEL") + v.BindEnv("gpt_image2.poll_max_wait", "GEN2D_GPT_IMAGE2_POLL_MAX_WAIT") + v.BindEnv("gpt_image2.poll_interval", "GEN2D_GPT_IMAGE2_POLL_INTERVAL") } diff --git a/backend/internal/service/gptimage.go b/backend/internal/service/gptimage.go new file mode 100755 index 0000000..a684b90 --- /dev/null +++ b/backend/internal/service/gptimage.go @@ -0,0 +1,268 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "gen2d/internal/config" +) + +// gptImage2Cfg 保存 GPT Image 2 配置。 +var gptImage2Cfg config.GptImage2Config + +// InitGptImage2Config 注入 GPT Image 2 配置。 +func InitGptImage2Config(cfg config.GptImage2Config) { + gptImage2Cfg = cfg +} + +// ======================== GPT Image 2 API 类型 ======================== + +// gpt2SubmitRequest 提交生图任务请求体。 +type gpt2SubmitRequest struct { + Prompt string `json:"prompt"` + AspectRatio string `json:"aspect_ratio,omitempty"` + ReferenceImages []string `json:"reference_images,omitempty"` + XChannel string `json:"x_channel,omitempty"` +} + +// gpt2SubmitResponse 提交生图任务响应体。 +type gpt2SubmitResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + } `json:"data"` +} + +// gpt2StatusRequest 查询任务状态请求体。 +type gpt2StatusRequest struct { + TaskID string `json:"task_id"` +} + +// gpt2StatusResponse 查询任务状态响应体。 +type gpt2StatusResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Progress int `json:"progress"` + ResultImageURL string `json:"result_image_url"` + ErrorMessage string `json:"error_message"` + } `json:"data"` +} + +// ======================== 公开接口 ======================== + +// GenerateImagesGpt2 通过 GPT Image 2 异步 API 生成图片。 +// count 张图通过并行提交+轮询实现。 +func GenerateImagesGpt2(ctx context.Context, prompt string, count int) ([]GeneratedImage, error) { + if gptImage2Cfg.APIKey == "" { + return nil, fmt.Errorf("gpt_image2 api_key not configured") + } + + // 并行提交任务 + type submitResult struct { + index int + taskID string + err error + } + results := make(chan submitResult, count) + for i := 0; i < count; i++ { + go func(idx int) { + taskID, err := submitGpt2Task(ctx, prompt) + results <- submitResult{index: idx, taskID: taskID, err: err} + }(i) + } + + // 收集 taskID + taskIDs := make([]string, count) + for i := 0; i < count; i++ { + r := <-results + if r.err != nil { + return nil, fmt.Errorf("submit task %d: %w", r.index, r.err) + } + taskIDs[r.index] = r.taskID + } + + // 并行轮询+下载 + type imageResult struct { + index int + image GeneratedImage + err error + } + imgResults := make(chan imageResult, count) + for i, tid := range taskIDs { + go func(idx int, taskID string) { + img, err := pollAndDownloadGpt2(ctx, taskID, prompt) + imgResults <- imageResult{index: idx, image: img, err: err} + }(i, tid) + } + + images := make([]GeneratedImage, count) + for i := 0; i < count; i++ { + r := <-imgResults + if r.err != nil { + return nil, fmt.Errorf("task %d: %w", r.index, r.err) + } + images[r.index] = r.image + } + + return images, nil +} + +// ======================== 内部实现 ======================== + +// submitGpt2Task 提交生图任务,返回 taskID。 +func submitGpt2Task(ctx context.Context, prompt string) (string, error) { + reqBody := gpt2SubmitRequest{ + Prompt: prompt, + AspectRatio: gptImage2Cfg.AspectRatio, + XChannel: gptImage2Cfg.XChannel, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal: %w", err) + } + + url := strings.TrimRight(gptImage2Cfg.BaseURL, "/") + "/gpt-image2/generate" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+gptImage2Cfg.APIKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("send: %w", err) + } + defer resp.Body.Close() + + var submitResp gpt2SubmitResponse + if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil { + return "", fmt.Errorf("decode: %w", err) + } + + if submitResp.Code != 200 { + return "", fmt.Errorf("submit failed: %s", submitResp.Message) + } + + log.Printf("[gpt_image2] task submitted: %s", submitResp.Data.TaskID) + return submitResp.Data.TaskID, nil +} + +// pollAndDownloadGpt2 轮询任务状态直到完成,然后下载图片。 +func pollAndDownloadGpt2(ctx context.Context, taskID, prompt string) (GeneratedImage, error) { + pollInterval := gptImage2Cfg.PollInterval + if pollInterval <= 0 { + pollInterval = 3 + } + maxWait := gptImage2Cfg.PollMaxWait + if maxWait <= 0 { + maxWait = 120 + } + + deadline := time.Now().Add(time.Duration(maxWait) * time.Second) + ticker := time.NewTicker(time.Duration(pollInterval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return GeneratedImage{}, ctx.Err() + case <-ticker.C: + status, err := queryGpt2Status(ctx, taskID) + if err != nil { + return GeneratedImage{}, fmt.Errorf("query status: %w", err) + } + + switch status.Data.Status { + case "completed": + log.Printf("[gpt_image2] task %s completed, downloading from %s", taskID, status.Data.ResultImageURL) + data, err := downloadGpt2Image(ctx, status.Data.ResultImageURL) + if err != nil { + return GeneratedImage{}, fmt.Errorf("download: %w", err) + } + return GeneratedImage{Data: data, Format: "png"}, nil + + case "failed": + errMsg := status.Data.ErrorMessage + if errMsg == "" { + errMsg = "unknown error" + } + return GeneratedImage{}, fmt.Errorf("generation failed: %s", errMsg) + + default: + log.Printf("[gpt_image2] task %s status=%s progress=%d", taskID, status.Data.Status, status.Data.Progress) + } + + if time.Now().After(deadline) { + return GeneratedImage{}, fmt.Errorf("poll timeout after %ds", maxWait) + } + } + } +} + +// queryGpt2Status 查询任务状态。 +func queryGpt2Status(ctx context.Context, taskID string) (*gpt2StatusResponse, error) { + reqBody := gpt2StatusRequest{TaskID: taskID} + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + url := strings.TrimRight(gptImage2Cfg.BaseURL, "/") + "/gpt-image2/status" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+gptImage2Cfg.APIKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send: %w", err) + } + defer resp.Body.Close() + + var statusResp gpt2StatusResponse + if err := json.NewDecoder(resp.Body).Decode(&statusResp); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + if statusResp.Code != 200 { + return nil, fmt.Errorf("status query failed: %s", statusResp.Message) + } + + return &statusResp, nil +} + +// downloadGpt2Image 下载生成的图片。 +func downloadGpt2Image(ctx context.Context, imageURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return nil, fmt.Errorf("create download request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download status %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} diff --git a/backend/internal/service/inference.go b/backend/internal/service/inference.go old mode 100644 new mode 100755 index bd55b0c..603b8d3 --- a/backend/internal/service/inference.go +++ b/backend/internal/service/inference.go @@ -48,13 +48,25 @@ type imageGenResponse struct { } `json:"data"` } -// GenerateImages 调用 AI 推理 API 生成图片,未配置 API key 时回退到 mock。 +// GenerateImages 调用 AI 推理 API 生成图片。 +// 优先级:GPT Image 2 > OpenAI 兼容 ImageGen > Mock 回退。 func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]GeneratedImage, error) { count := 1 if params.Frames.Directions > 0 && params.Frames.FramesPerDirection > 0 { count = params.Frames.Directions * params.Frames.FramesPerDirection } + // 优先使用 GPT Image 2 异步 API + if gptImage2Cfg.APIKey != "" { + log.Println("[inference] using GPT Image 2 async API") + images, err := GenerateImagesGpt2(ctx, prompt, count) + if err != nil { + return nil, fmt.Errorf("gpt_image2: %w", err) + } + return images, nil + } + + // 降级:OpenAI 兼容 Images API if imgCfg.APIKey != "" { width, height := imgCfg.Width, imgCfg.Height if params.Resolution > 0 { @@ -64,11 +76,12 @@ func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]G return callImageGenAPI(ctx, prompt, count, width, height) } + // 最终降级:mock 占位图 size := params.Resolution if size <= 0 { size = 64 } - log.Println("[inference] ImageGen API key not configured, using mock") + log.Println("[inference] no image API key configured, using mock") return generateMockImages(size, count) }