feat(inference): 集成 GPT Image 2 异步文生图 API
- 新增 GptImage2Config 配置(yuntts 等 GPT Image 2 兼容服务) - 新增 gptimage.go 异步客户端:提交任务 → 轮询状态 → 下载图片 - GenerateImages 优先级调整为:GPT Image 2 > OpenAI 兼容 ImageGen > Mock - 支持环境变量 GEN2D_GPT_IMAGE2_* 系列配置
This commit is contained in:
Regular → Executable
+10
-1
@@ -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
|
||||
|
||||
@@ -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 恢复中间件,防止服务因未捕获异常宕机
|
||||
|
||||
Regular → Executable
+31
-6
@@ -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")
|
||||
}
|
||||
|
||||
Executable
+268
@@ -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)
|
||||
}
|
||||
Regular → Executable
+15
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user