diff --git a/Dockerfile b/Dockerfile index ad9860e..e912036 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ COPY web/bun.lock . RUN bun install COPY ./web . COPY ./VERSION . -RUN DISABLE_ESLINT_PLUGIN='true' NODE_OPTIONS="--max-old-space-size=3072" VITE_REACT_APP_VERSION=$(cat VERSION) bun run build +RUN DISABLE_ESLINT_PLUGIN='true' NODE_OPTIONS="--max-old-space-size=4096" VITE_REACT_APP_VERSION=$(cat VERSION) bun run build FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 ENV GO111MODULE=on CGO_ENABLED=0 diff --git a/Dockerfile.bak.20260326 b/Dockerfile.bak.20260326 deleted file mode 100644 index aa43de1..0000000 --- a/Dockerfile.bak.20260326 +++ /dev/null @@ -1,38 +0,0 @@ -FROM oven/bun:latest AS builder - -WORKDIR /build -COPY web/package.json . -COPY web/bun.lock . -RUN bun install -COPY ./web . -COPY ./VERSION . -RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build - -FROM golang:alpine AS builder2 -ENV GO111MODULE=on CGO_ENABLED=0 - -ARG TARGETOS -ARG TARGETARCH -ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} -ENV GOEXPERIMENT=greenteagc - -WORKDIR /build - -ADD go.mod go.sum ./ -RUN go mod download - -COPY . . -COPY --from=builder /build/dist ./web/dist -RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api - -FROM debian:bookworm-slim - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \ - && rm -rf /var/lib/apt/lists/* \ - && update-ca-certificates - -COPY --from=builder2 /build/new-api / -EXPOSE 3000 -WORKDIR /data -ENTRYPOINT ["/new-api"] diff --git a/common/endpoint_type.go b/common/endpoint_type.go index 28ddd8f..8bd4389 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -39,8 +39,10 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant } } if IsImageGenerationModel(modelName) { - // add to first endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...) } + if IsImageEditModel(modelName) { + endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageEdit}, endpointTypes...) + } return endpointTypes } diff --git a/common/model.go b/common/model.go index 4ebc7b5..db32a06 100644 --- a/common/model.go +++ b/common/model.go @@ -13,10 +13,16 @@ var ( "dall-e-3", "dall-e-2", "gpt-image-1", + "gpt-image-2", "prefix:imagen-", "flux-", "flux.1-", } + ImageEditModels = []string{ + "gpt-image-1", + "gpt-image-2", + "prefix:qwen-image-edit", + } OpenAITextModels = []string{ "gpt-", "o1", @@ -48,6 +54,19 @@ func IsImageGenerationModel(modelName string) bool { return false } +func IsImageEditModel(modelName string) bool { + modelName = strings.ToLower(modelName) + for _, m := range ImageEditModels { + if strings.Contains(modelName, m) { + return true + } + if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) { + return true + } + } + return false +} + func IsOpenAITextModel(modelName string) bool { modelName = strings.ToLower(modelName) for _, m := range OpenAITextModels { diff --git a/controller/image_studio.go b/controller/image_studio.go new file mode 100644 index 0000000..155d8d7 --- /dev/null +++ b/controller/image_studio.go @@ -0,0 +1,1087 @@ +package controller + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/gin-gonic/gin" +) + +const ( + imageStudioGroup = "image" + imageStudioTaskStatusPending = "pending" + imageStudioTaskStatusRunning = "running" + imageStudioTaskStatusSuccess = "success" + imageStudioTaskStatusFailure = "failure" + imageStudioTaskTTL = 30 * time.Minute + imageStudioTaskCleanupEvery = 5 * time.Minute + imageStudioHTTPTimeout = 10 * time.Minute + imageStudioGenerateAction = "generate" + imageStudioEditAction = "edit" + imageStudioResultBaseDir = "/data/image-studio-results" + imageStudioResultURLPrefix = "/image-studio-results" +) + +type imageStudioGenerateRequest struct { + TokenID int `json:"token_id"` + Model string `json:"model"` + Prompt string `json:"prompt"` + Size string `json:"size"` + Quality string `json:"quality"` +} + +type imageStudioUploadedImage struct { + Filename string + ContentType string + Data []byte +} + +type imageStudioTask struct { + ID string `json:"task_id"` + UserID int `json:"-"` + Mode string `json:"mode"` + Model string `json:"model"` + Size string `json:"size"` + Quality string `json:"quality"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + ResultDir string `json:"-"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +var ( + imageStudioTasks = struct { + sync.RWMutex + items map[string]*imageStudioTask + }{items: make(map[string]*imageStudioTask)} + imageStudioCleanupOnce sync.Once +) + +func ensureImageStudioTaskCleanup() { + imageStudioCleanupOnce.Do(func() { + go func() { + ticker := time.NewTicker(imageStudioTaskCleanupEvery) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-imageStudioTaskTTL).Unix() + imageStudioTasks.Lock() + for id, task := range imageStudioTasks.items { + if task.UpdatedAt < cutoff { + cleanupImageStudioTaskArtifacts(task) + delete(imageStudioTasks.items, id) + } + } + imageStudioTasks.Unlock() + } + }() + }) +} + +func newImageStudioTask(userID int, mode string, req imageStudioGenerateRequest) (*imageStudioTask, error) { + ensureImageStudioTaskCleanup() + key, err := common.GenerateRandomCharsKey(24) + if err != nil { + return nil, err + } + now := time.Now().Unix() + task := &imageStudioTask{ + ID: "imgtask_" + key, + UserID: userID, + Mode: mode, + Model: strings.TrimSpace(req.Model), + Size: resolveImageStudioSize(req.Model, req.Size), + Quality: normalizeImageStudioQuality(req.Quality), + Status: imageStudioTaskStatusPending, + CreatedAt: now, + UpdatedAt: now, + } + imageStudioTasks.Lock() + imageStudioTasks.items[task.ID] = task + imageStudioTasks.Unlock() + return task, nil +} + +func getImageStudioTask(taskID string) (*imageStudioTask, bool) { + imageStudioTasks.RLock() + defer imageStudioTasks.RUnlock() + task, ok := imageStudioTasks.items[taskID] + return task, ok +} + +func updateImageStudioTask(taskID string, updateFn func(task *imageStudioTask)) { + imageStudioTasks.Lock() + defer imageStudioTasks.Unlock() + if task, ok := imageStudioTasks.items[taskID]; ok { + updateFn(task) + task.UpdatedAt = time.Now().Unix() + } +} + +func cleanupImageStudioTaskArtifacts(task *imageStudioTask) { + if task == nil || strings.TrimSpace(task.ResultDir) == "" { + return + } + baseDir := filepath.Clean(imageStudioResultBaseDir) + resultDir := filepath.Clean(task.ResultDir) + prefix := baseDir + string(os.PathSeparator) + if resultDir == baseDir || !strings.HasPrefix(resultDir, prefix) { + return + } + _ = os.RemoveAll(resultDir) + task.ResultDir = "" +} + +func normalizeImageStudioSize(v string) string { + value := strings.TrimSpace(strings.ToLower(v)) + switch value { + case "", "auto": + return "auto" + case "1024x1024", "1536x1024", "1024x1536", "2048x2048", "2048x1152", "1152x2048", "3840x2160", "2160x3840": + return value + default: + return "auto" + } +} + +func normalizeImageStudioModel(v string) string { + return strings.TrimSpace(strings.ToLower(v)) +} + +func isGPTImage2Model(modelName string) bool { + return normalizeImageStudioModel(modelName) == "gpt-image-2" +} + +func isValidGPTImage2Size(size string) bool { + if size == "" || size == "auto" { + return true + } + parts := strings.Split(size, "x") + if len(parts) != 2 { + return false + } + width, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + height, err := strconv.Atoi(parts[1]) + if err != nil { + return false + } + if width <= 0 || height <= 0 { + return false + } + if width > 3840 || height > 3840 { + return false + } + if width%16 != 0 || height%16 != 0 { + return false + } + longEdge := width + shortEdge := height + if height > width { + longEdge = height + shortEdge = width + } + if longEdge > shortEdge*3 { + return false + } + pixels := width * height + if pixels < 655360 || pixels > 8294400 { + return false + } + return true +} + +func resolveImageStudioSize(modelName string, size string) string { + normalizedSize := normalizeImageStudioSize(size) + if isGPTImage2Model(modelName) { + candidate := strings.TrimSpace(strings.ToLower(size)) + if isValidGPTImage2Size(candidate) { + return candidate + } + if normalizedSize != "auto" { + return normalizedSize + } + } + return normalizedSize +} + +func normalizeImageStudioQuality(v string) string { + value := strings.TrimSpace(strings.ToLower(v)) + switch value { + case "", "auto": + return "auto" + case "low", "medium", "high": + return value + default: + return "auto" + } +} + +func normalizeImageStudioBearerToken(key string) string { + trimmed := strings.TrimSpace(key) + if strings.HasPrefix(trimmed, "sk-") { + return trimmed + } + return "sk-" + trimmed +} + +var imageStudioDisallowedKeywords = []string{ + "暴力", + "血腥", + "伤害", + "虐待", + "自残", + "自杀", + "恐怖", + "战争", + "武器", + "尸体", + "残肢", + "恶心", + "色情", + "裸露", + "性暗示", + "卖淫", + "约炮", + "成人视频", + "强奷", + "sm", + "性暴力", + "性服务", + "赌博", + "博彩", + "赌场", + "下注", + "洗钱", + "毒品", + "制毒", + "吸毒", + "贩毒", + "违禁药物", + "政治敏感", + "反动", + "分裂", + "颠覆", + "煽动", + "国家领导人", + "国旗", + "国徽", + "国歌", + "恐怖主义", + "暴恐", +} + +func buildImageStudioSafetyPrompt() string { + return setting.ImageStudioSafetyPrompt +} + +func normalizeImageStudioPromptForSafety(prompt string) string { + replacer := strings.NewReplacer( + " ", "", + "\n", "", + "\r", "", + "\t", "", + "-", "", + "_", "", + "/", "", + "\\", "", + "|", "", + "*", "", + ".", "", + ",", "", + ",", "", + "。", "", + ":", "", + ":", "", + ";", "", + ";", "", + "(", "", + ")", "", + "(", "", + ")", "", + "[", "", + "]", "", + "【", "", + "】", "", + "{", "", + "}", "", + "<", "", + ">", "", + "《", "", + "》", "", + "'", "", + "\"", "", + "`", "", + "!", "", + "!", "", + "?", "", + "?", "", + ) + return strings.ToLower(replacer.Replace(strings.TrimSpace(prompt))) +} + +func containsImageStudioDisallowedKeyword(prompt string) (bool, string) { + checkText := normalizeImageStudioPromptForSafety(prompt) + for _, keyword := range setting.ImageStudioBannedWords { + k := normalizeImageStudioPromptForSafety(keyword) + if k == "" { + continue + } + if strings.Contains(checkText, k) { + return true, keyword + } + } + return false, "" +} + +func validateImageStudioPromptSafety(prompt string) error { + trimmed := strings.TrimSpace(prompt) + if trimmed == "" { + return fmt.Errorf("请先填写提示词") + } + if ok, keyword := containsImageStudioDisallowedKeyword(trimmed); ok { + return fmt.Errorf("提示词包含禁止内容:%s", keyword) + } + if ok, words := service.SensitiveWordContains(trimmed); ok { + if len(words) > 0 { + return fmt.Errorf("提示词包含敏感内容:%s", strings.Join(words, ", ")) + } + return fmt.Errorf("提示词包含敏感内容,请修改后重试") + } + return nil +} + +func imageStudioModeEndpointType(mode string) constant.EndpointType { + if mode == imageStudioEditAction { + return constant.EndpointTypeImageEdit + } + return constant.EndpointTypeImageGeneration +} + +func imageStudioModePath(mode string) string { + if mode == imageStudioEditAction { + return "/v1/images/edits" + } + return "/v1/images/generations" +} + +func resolveImageStudioGenerateRelayAction(images []imageStudioUploadedImage) string { + if len(images) > 0 { + return imageStudioEditAction + } + return imageStudioGenerateAction +} + +func validateImageStudioTokenAndModel(userID int, req imageStudioGenerateRequest, mode string) (*model.Token, error) { + token, err := model.GetTokenByIds(req.TokenID, userID) + if err != nil { + return nil, fmt.Errorf("未找到所选令牌,请刷新后重试") + } + if token.Status != common.TokenStatusEnabled { + return nil, fmt.Errorf("当前令牌状态不可用,请去控制台-令牌管理检查该令牌") + } + if strings.TrimSpace(token.Group) != imageStudioGroup { + return nil, fmt.Errorf("所选令牌不是 image 分组,请去控制台-令牌管理创建或选择 image 分组令牌") + } + modelName := strings.TrimSpace(req.Model) + if modelName == "" { + return nil, fmt.Errorf("请选择图片模型") + } + if isGPTImage2Model(modelName) && !isValidGPTImage2Size(strings.TrimSpace(strings.ToLower(req.Size))) { + return nil, fmt.Errorf("gpt-image-2 尺寸不合法:最长边不能超过 3840,边长需为 16 的倍数,长宽比不能超过 3:1,总像素需在 655360 到 8294400 之间") + } + if token.ModelLimitsEnabled { + limits := token.GetModelLimitsMap() + if len(limits) == 0 || !limits[modelName] { + return nil, fmt.Errorf("当前令牌没有放行该图片模型,请去控制台-令牌管理调整模型限制") + } + } + + pricingItems := model.GetPricing() + var matched *model.Pricing + for index := range pricingItems { + item := pricingItems[index] + if item.ModelName != modelName { + continue + } + if !containsString(item.EnableGroup, imageStudioGroup) { + continue + } + matched = &item + break + } + if matched == nil { + return nil, fmt.Errorf("当前 image 分组未配置该图片模型,请联系管理员检查 image 分组和模型配置") + } + if !containsEndpointType(matched.SupportedEndpointTypes, imageStudioModeEndpointType(mode)) { + if mode == imageStudioEditAction { + return nil, fmt.Errorf("当前模型不支持编辑图片,请联系管理员检查模型能力配置") + } + return nil, fmt.Errorf("当前模型不支持生成图片,请联系管理员检查模型能力配置") + } + return token, nil +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if strings.TrimSpace(item) == target { + return true + } + } + return false +} + +func containsEndpointType(items []constant.EndpointType, target constant.EndpointType) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + +func readImageStudioUploads(c *gin.Context, required bool) ([]imageStudioUploadedImage, error) { + form, err := c.MultipartForm() + if err != nil { + return nil, err + } + fileHeaders := form.File["image[]"] + if len(fileHeaders) == 0 { + fileHeaders = form.File["images[]"] + } + if len(fileHeaders) == 0 { + fileHeaders = form.File["image"] + } + if len(fileHeaders) == 0 { + if required { + return nil, fmt.Errorf("编辑图片至少需要上传一张参考图") + } + return nil, nil + } + images := make([]imageStudioUploadedImage, 0, len(fileHeaders)) + for _, header := range fileHeaders { + image, err := readImageStudioUpload(header) + if err != nil { + return nil, err + } + images = append(images, image) + } + return images, nil +} + +func readImageStudioUpload(header *multipart.FileHeader) (imageStudioUploadedImage, error) { + file, err := header.Open() + if err != nil { + return imageStudioUploadedImage{}, err + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + return imageStudioUploadedImage{}, err + } + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = http.DetectContentType(data) + } + return imageStudioUploadedImage{ + Filename: header.Filename, + ContentType: contentType, + Data: data, + }, nil +} + +func buildImageStudioInternalURL(mode string) string { + return fmt.Sprintf("http://127.0.0.1:%d%s", *common.Port, imageStudioModePath(mode)) +} + +func decodeImageStudioError(body []byte, fallback string) string { + if len(body) == 0 { + return fallback + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err == nil { + if errorPart, ok := payload["error"].(map[string]any); ok { + if message, ok := errorPart["message"].(string); ok && strings.TrimSpace(message) != "" { + return message + } + } + if message, ok := payload["message"].(string); ok && strings.TrimSpace(message) != "" { + return message + } + } + return fallback +} + +func getImageStudioResultExtension(outputFormat string) string { + switch strings.ToLower(strings.TrimSpace(outputFormat)) { + case "jpg", "jpeg": + return "jpg" + case "webp": + return "webp" + default: + return "png" + } +} + +func signImageStudioResult(relativePath string, expiresAt int64) string { + mac := hmac.New(sha256.New, []byte(common.CryptoSecret)) + _, _ = mac.Write([]byte(relativePath)) + _, _ = mac.Write([]byte("\n")) + _, _ = mac.Write([]byte(strconv.FormatInt(expiresAt, 10))) + return hex.EncodeToString(mac.Sum(nil)) +} + +func buildImageStudioResultFileURL(taskID string, index int, outputFormat string) string { + relativePath := fmt.Sprintf("%s/%d.%s", taskID, index, getImageStudioResultExtension(outputFormat)) + expiresAt := time.Now().Add(imageStudioTaskTTL).Unix() + signature := signImageStudioResult(relativePath, expiresAt) + return fmt.Sprintf("%s/%s?expires=%d&sig=%s", imageStudioResultURLPrefix, relativePath, expiresAt, signature) +} + +func persistImageStudioTaskResult(taskID string, respBody []byte) (json.RawMessage, string, error) { + var payload map[string]any + if err := json.Unmarshal(respBody, &payload); err != nil { + return nil, "", err + } + data, ok := payload["data"].([]any) + if !ok || len(data) == 0 { + return json.RawMessage(respBody), "", nil + } + resultDir := filepath.Join(imageStudioResultBaseDir, taskID) + wroteFiles := false + for index, rawItem := range data { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + b64, _ := item["b64_json"].(string) + b64 = strings.TrimSpace(b64) + if b64 == "" { + continue + } + decoded, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + _ = os.RemoveAll(resultDir) + return nil, "", fmt.Errorf("图片结果解码失败") + } + if !wroteFiles { + _ = os.RemoveAll(resultDir) + if err := os.MkdirAll(resultDir, 0o755); err != nil { + return nil, "", fmt.Errorf("图片结果目录创建失败") + } + } + outputFormat, _ := item["output_format"].(string) + ext := getImageStudioResultExtension(outputFormat) + filename := fmt.Sprintf("%d.%s", index, ext) + fullPath := filepath.Join(resultDir, filename) + if err := os.WriteFile(fullPath, decoded, 0o644); err != nil { + _ = os.RemoveAll(resultDir) + return nil, "", fmt.Errorf("图片结果写入失败") + } + delete(item, "b64_json") + item["url"] = buildImageStudioResultFileURL(taskID, index, outputFormat) + wroteFiles = true + } + if !wroteFiles { + return json.RawMessage(respBody), "", nil + } + encoded, err := json.Marshal(payload) + if err != nil { + _ = os.RemoveAll(resultDir) + return nil, "", fmt.Errorf("图片结果序列化失败") + } + return json.RawMessage(encoded), resultDir, nil +} + +func executeImageStudioGenerateTask(taskID string, tokenKey string, req imageStudioGenerateRequest, images []imageStudioUploadedImage) { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusRunning + task.Error = "" + }) + + relayAction := resolveImageStudioGenerateRelayAction(images) + var body bytes.Buffer + contentType := "application/json" + if len(images) > 0 { + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", strings.TrimSpace(req.Model)) + _ = writer.WriteField("prompt", buildImageStudioSafetyPrompt()+"\n\n"+strings.TrimSpace(req.Prompt)) + _ = writer.WriteField("size", resolveImageStudioSize(req.Model, req.Size)) + _ = writer.WriteField("quality", normalizeImageStudioQuality(req.Quality)) + _ = writer.WriteField("response_format", "b64_json") + _ = writer.WriteField("output_format", "png") + + for _, image := range images { + headers := make(textproto.MIMEHeader) + headers.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image[]"; filename="%s"`, image.Filename)) + headers.Set("Content-Type", image.ContentType) + + part, err := writer.CreatePart(headers) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "图片生成参考图封装失败" + }) + _ = writer.Close() + return + } + if _, err = part.Write(image.Data); err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "图片生成参考图写入失败" + }) + _ = writer.Close() + return + } + } + if err := writer.Close(); err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "图片生成请求构建失败" + }) + return + } + contentType = writer.FormDataContentType() + } else { + payload, err := json.Marshal(gin.H{ + "model": strings.TrimSpace(req.Model), + "prompt": buildImageStudioSafetyPrompt() + "\n\n" + strings.TrimSpace(req.Prompt), + "size": resolveImageStudioSize(req.Model, req.Size), + "quality": normalizeImageStudioQuality(req.Quality), + "response_format": "b64_json", + "output_format": "png", + }) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "图片生成请求构建失败" + }) + return + } + body.Write(payload) + } + + httpReq, err := http.NewRequest(http.MethodPost, buildImageStudioInternalURL(relayAction), bytes.NewReader(body.Bytes())) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "图片生成请求创建失败" + }) + return + } + httpReq.Header.Set("Authorization", "Bearer "+normalizeImageStudioBearerToken(tokenKey)) + httpReq.Header.Set("Content-Type", contentType) + + client := &http.Client{Timeout: imageStudioHTTPTimeout} + resp, err := client.Do(httpReq) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "图片生成请求失败:" + err.Error() + }) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "读取图片生成结果失败" + }) + return + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = decodeImageStudioError(respBody, "图片生成失败") + }) + return + } + + sanitizedResult, resultDir, err := persistImageStudioTaskResult(taskID, respBody) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = err.Error() + }) + return + } + + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusSuccess + cleanupImageStudioTaskArtifacts(task) + task.Result = sanitizedResult + task.ResultDir = resultDir + task.Error = "" + }) +} + +func executeImageStudioEditTask(taskID string, tokenKey string, req imageStudioGenerateRequest, images []imageStudioUploadedImage) { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusRunning + task.Error = "" + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", strings.TrimSpace(req.Model)) + _ = writer.WriteField("prompt", buildImageStudioSafetyPrompt()+"\n\n"+strings.TrimSpace(req.Prompt)) + _ = writer.WriteField("size", resolveImageStudioSize(req.Model, req.Size)) + _ = writer.WriteField("quality", normalizeImageStudioQuality(req.Quality)) + _ = writer.WriteField("response_format", "b64_json") + _ = writer.WriteField("output_format", "png") + + for _, image := range images { + headers := make(textproto.MIMEHeader) + headers.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image[]"; filename="%s"`, image.Filename)) + headers.Set("Content-Type", image.ContentType) + + part, err := writer.CreatePart(headers) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "编辑图片参考图封装失败" + }) + _ = writer.Close() + return + } + if _, err = part.Write(image.Data); err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "编辑图片参考图写入失败" + }) + _ = writer.Close() + return + } + } + if err := writer.Close(); err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "编辑图片请求构建失败" + }) + return + } + + httpReq, err := http.NewRequest(http.MethodPost, buildImageStudioInternalURL(imageStudioEditAction), bytes.NewReader(body.Bytes())) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "编辑图片请求创建失败" + }) + return + } + httpReq.Header.Set("Authorization", "Bearer "+normalizeImageStudioBearerToken(tokenKey)) + httpReq.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: imageStudioHTTPTimeout} + resp, err := client.Do(httpReq) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "编辑图片请求失败:" + err.Error() + }) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = "读取编辑图片结果失败" + }) + return + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = decodeImageStudioError(respBody, "图片编辑失败") + }) + return + } + + sanitizedResult, resultDir, err := persistImageStudioTaskResult(taskID, respBody) + if err != nil { + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusFailure + task.Error = err.Error() + }) + return + } + + updateImageStudioTask(taskID, func(task *imageStudioTask) { + task.Status = imageStudioTaskStatusSuccess + cleanupImageStudioTaskArtifacts(task) + task.Result = sanitizedResult + task.ResultDir = resultDir + task.Error = "" + }) +} + +func SubmitImageStudioGenerate(c *gin.Context) { + userID := c.GetInt("id") + var req imageStudioGenerateRequest + var images []imageStudioUploadedImage + var err error + + if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "multipart/form-data") { + req = imageStudioGenerateRequest{ + Model: c.PostForm("model"), + Prompt: strings.TrimSpace(c.PostForm("prompt")), + Size: c.PostForm("size"), + Quality: c.PostForm("quality"), + } + if tokenID := strings.TrimSpace(c.PostForm("token_id")); tokenID != "" { + _, _ = fmt.Sscanf(tokenID, "%d", &req.TokenID) + } + images, err = readImageStudioUploads(c, false) + if err != nil { + common.ApiErrorMsg(c, "图片生成参考图读取失败,请重新上传后重试") + return + } + } else if err = c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "图片生成参数格式不正确,请刷新后重试") + return + } + + req.Prompt = strings.TrimSpace(req.Prompt) + if err := validateImageStudioPromptSafety(req.Prompt); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + validationMode := imageStudioGenerateAction + if len(images) > 0 { + validationMode = imageStudioEditAction + } + token, err := validateImageStudioTokenAndModel(userID, req, validationMode) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + task, err := newImageStudioTask(userID, imageStudioGenerateAction, req) + if err != nil { + common.ApiErrorMsg(c, "创建图片任务失败,请稍后重试") + return + } + go executeImageStudioGenerateTask(task.ID, token.GetFullKey(), req, images) + common.ApiSuccess(c, task) +} + +func SubmitImageStudioEdit(c *gin.Context) { + userID := c.GetInt("id") + req := imageStudioGenerateRequest{ + Model: c.PostForm("model"), + Prompt: strings.TrimSpace(c.PostForm("prompt")), + Size: c.PostForm("size"), + Quality: c.PostForm("quality"), + } + if tokenID := strings.TrimSpace(c.PostForm("token_id")); tokenID != "" { + _, _ = fmt.Sscanf(tokenID, "%d", &req.TokenID) + } + if err := validateImageStudioPromptSafety(req.Prompt); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + images, err := readImageStudioUploads(c, true) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + token, err := validateImageStudioTokenAndModel(userID, req, imageStudioEditAction) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + task, err := newImageStudioTask(userID, imageStudioEditAction, req) + if err != nil { + common.ApiErrorMsg(c, "创建图片任务失败,请稍后重试") + return + } + go executeImageStudioEditTask(task.ID, token.GetFullKey(), req, images) + common.ApiSuccess(c, task) +} + +func buildImageStudioTaskResultURL(taskID string, index int) string { + return fmt.Sprintf("/api/image-studio/task/%s/result/%d", taskID, index) +} + +func getImageStudioResultContentType(outputFormat string) string { + switch strings.ToLower(strings.TrimSpace(outputFormat)) { + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + default: + return "image/png" + } +} + +func sanitizeImageStudioTaskForResponse(task *imageStudioTask) *imageStudioTask { + if task == nil { + return nil + } + clone := *task + if clone.Status != imageStudioTaskStatusSuccess || len(clone.Result) == 0 { + return &clone + } + var payload map[string]any + if err := json.Unmarshal(clone.Result, &payload); err != nil { + return &clone + } + data, ok := payload["data"].([]any) + if !ok || len(data) == 0 { + return &clone + } + changed := false + for index, rawItem := range data { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + if b64, _ := item["b64_json"].(string); strings.TrimSpace(b64) != "" { + delete(item, "b64_json") + item["url"] = buildImageStudioTaskResultURL(task.ID, index) + changed = true + } + } + if !changed { + return &clone + } + if encoded, err := json.Marshal(payload); err == nil { + clone.Result = json.RawMessage(encoded) + } + return &clone +} + +func GetImageStudioTask(c *gin.Context) { + userID := c.GetInt("id") + taskID := strings.TrimSpace(c.Param("id")) + if taskID == "" { + common.ApiErrorMsg(c, "任务不存在") + return + } + task, ok := getImageStudioTask(taskID) + if !ok || task == nil || task.UserID != userID { + common.ApiErrorMsg(c, "任务不存在或已过期,请重新提交") + return + } + common.ApiSuccess(c, sanitizeImageStudioTaskForResponse(task)) +} + +func GetImageStudioTaskResult(c *gin.Context) { + userID := c.GetInt("id") + taskID := strings.TrimSpace(c.Param("id")) + if taskID == "" { + common.ApiErrorMsg(c, "任务不存在") + return + } + index, err := strconv.Atoi(strings.TrimSpace(c.Param("index"))) + if err != nil || index < 0 { + common.ApiErrorMsg(c, "图片结果不存在") + return + } + task, ok := getImageStudioTask(taskID) + if !ok || task == nil || task.UserID != userID { + common.ApiErrorMsg(c, "任务不存在或已过期,请重新提交") + return + } + if task.Status != imageStudioTaskStatusSuccess || len(task.Result) == 0 { + common.ApiErrorMsg(c, "图片结果尚未生成完成") + return + } + var payload map[string]any + if err := json.Unmarshal(task.Result, &payload); err != nil { + common.ApiErrorMsg(c, "图片结果解析失败") + return + } + data, ok := payload["data"].([]any) + if !ok || index >= len(data) { + common.ApiErrorMsg(c, "图片结果不存在") + return + } + item, ok := data[index].(map[string]any) + if !ok { + common.ApiErrorMsg(c, "图片结果不存在") + return + } + if url, _ := item["url"].(string); strings.TrimSpace(url) != "" { + c.Redirect(http.StatusTemporaryRedirect, url) + return + } + b64, _ := item["b64_json"].(string) + if strings.TrimSpace(b64) == "" { + common.ApiErrorMsg(c, "图片结果不存在") + return + } + decoded, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + common.ApiErrorMsg(c, "图片结果解码失败") + return + } + contentType := getImageStudioResultContentType(fmt.Sprintf("%v", item["output_format"])) + c.Header("Cache-Control", "private, no-store, max-age=0") + c.Data(http.StatusOK, contentType, decoded) +} + +func GetImageStudioResultFile(c *gin.Context) { + relativePath := strings.TrimPrefix(c.Param("filepath"), "/") + relativePath = strings.TrimSpace(relativePath) + if relativePath == "" { + c.AbortWithStatus(http.StatusNotFound) + return + } + expiresAt, err := strconv.ParseInt(strings.TrimSpace(c.Query("expires")), 10, 64) + if err != nil || expiresAt <= 0 || time.Now().Unix() > expiresAt { + c.AbortWithStatus(http.StatusForbidden) + return + } + sig := strings.TrimSpace(c.Query("sig")) + if sig == "" { + c.AbortWithStatus(http.StatusForbidden) + return + } + expected := signImageStudioResult(relativePath, expiresAt) + if !hmac.Equal([]byte(strings.ToLower(sig)), []byte(expected)) { + c.AbortWithStatus(http.StatusForbidden) + return + } + baseDir := filepath.Clean(imageStudioResultBaseDir) + fullPath := filepath.Clean(filepath.Join(baseDir, filepath.Clean(relativePath))) + prefix := baseDir + string(os.PathSeparator) + if fullPath == baseDir || !strings.HasPrefix(fullPath, prefix) { + c.AbortWithStatus(http.StatusNotFound) + return + } + info, err := os.Stat(fullPath) + if err != nil || info.IsDir() { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.Header("Cache-Control", "private, no-store, no-cache, max-age=0") + c.Header("Pragma", "no-cache") + c.File(fullPath) +} diff --git a/controller/secure_verification.go b/controller/secure_verification.go index b229a66..947deb6 100644 --- a/controller/secure_verification.go +++ b/controller/secure_verification.go @@ -16,6 +16,10 @@ const ( SecureVerificationSessionKey = "secure_verified_at" // PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification. PasskeyReadySessionKey = "secure_passkey_ready_at" + // secureVerificationMethodSessionKey stores the method that completed step-up verification. + secureVerificationMethodSessionKey = "secure_verification_method" + secureVerificationMethod2FA = "2fa" + secureVerificationMethodPasskey = "passkey" // SecureVerificationTimeout 验证有效期(秒) SecureVerificationTimeout = 300 // 5分钟 // PasskeyReadyTimeout passkey ready 标记有效期(秒) @@ -77,6 +81,7 @@ func UniversalVerify(c *gin.Context) { // 根据验证方式进行验证 var verified bool var verifyMethod string + var sessionMethod string var err error switch req.Method { @@ -91,6 +96,7 @@ func UniversalVerify(c *gin.Context) { } verified = validateTwoFactorAuth(twoFA, req.Code) verifyMethod = "2FA" + sessionMethod = secureVerificationMethod2FA case "passkey": if !hasPasskey { @@ -108,6 +114,7 @@ func UniversalVerify(c *gin.Context) { return } verifyMethod = "Passkey" + sessionMethod = secureVerificationMethodPasskey default: common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method)) @@ -120,7 +127,7 @@ func UniversalVerify(c *gin.Context) { } // 验证成功,在 session 中记录时间戳 - now, err := setSecureVerificationSession(c) + now, err := setSecureVerificationSession(c, sessionMethod) if err != nil { common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) return @@ -139,11 +146,16 @@ func UniversalVerify(c *gin.Context) { }) } -func setSecureVerificationSession(c *gin.Context) (int64, error) { +func setSecureVerificationSession(c *gin.Context, method string) (int64, error) { session := sessions.Default(c) session.Delete(PasskeyReadySessionKey) now := time.Now().Unix() session.Set(SecureVerificationSessionKey, now) + if method != "" { + session.Set(secureVerificationMethodSessionKey, method) + } else { + session.Delete(secureVerificationMethodSessionKey) + } if err := session.Save(); err != nil { return 0, err } diff --git a/controller/user.go b/controller/user.go index 4ec64e2..f636e30 100644 --- a/controller/user.go +++ b/controller/user.go @@ -475,6 +475,12 @@ func generateDefaultSidebarConfig(userRole int) string { "personal": true, } + // 其他功能区域 - 所有用户都可以访问 + defaultConfig["other"] = map[string]interface{}{ + "enabled": true, + "image_studio": true, + } + // 管理员区域 - 根据角色决定 if userRole == common.RoleAdminUser { // 管理员可以访问管理员区域,但不能访问系统设置 diff --git a/middleware/distributor.go b/middleware/distributor.go index d626941..8805134 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -291,10 +291,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { modelRequest.Model = c.Param("model") } } - if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { - modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") - } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { - //modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1") + if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") || strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { contentType := c.ContentType() if slices.Contains([]string{gin.MIMEPOSTForm, gin.MIMEMultipartPOSTForm}, contentType) { req, err := getModelFromRequest(c) @@ -302,6 +299,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { modelRequest.Model = req.Model } } + if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") + } } if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { relayMode := relayconstant.RelayModeAudioSpeech diff --git a/model/option.go b/model/option.go index f6cfdc2..4984af1 100644 --- a/model/option.go +++ b/model/option.go @@ -160,6 +160,8 @@ func InitOptionMap() { common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled) common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled) common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() + common.OptionMap["ImageStudioSafetyPrompt"] = setting.ImageStudioSafetyPromptToString() + common.OptionMap["ImageStudioBannedWords"] = setting.ImageStudioBannedWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() @@ -497,6 +499,10 @@ func updateOptionMap(key string, value string) (err error) { common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64) case "SensitiveWords": setting.SensitiveWordsFromString(value) + case "ImageStudioSafetyPrompt": + setting.ImageStudioSafetyPromptFromString(value) + case "ImageStudioBannedWords": + setting.ImageStudioBannedWordsFromString(value) case "AutomaticDisableKeywords": operation_setting.AutomaticDisableKeywordsFromString(value) case "AutomaticDisableStatusCodes": diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 29a8f34..5d833dc 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -434,7 +434,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { switch info.RelayMode { - case relayconstant.RelayModeImagesEdits: + case relayconstant.RelayModeImagesEdits, relayconstant.RelayModeImagesGenerations: + if info.RelayMode == relayconstant.RelayModeImagesGenerations && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + return request, nil + } var requestBody bytes.Buffer writer := multipart.NewWriter(&requestBody) @@ -602,7 +605,8 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { if info.RelayMode == relayconstant.RelayModeAudioTranscription || info.RelayMode == relayconstant.RelayModeAudioTranslation || - info.RelayMode == relayconstant.RelayModeImagesEdits { + info.RelayMode == relayconstant.RelayModeImagesEdits || + (info.RelayMode == relayconstant.RelayModeImagesGenerations && strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data")) { return channel.DoFormRequest(a, c, info, requestBody) } else if info.RelayMode == relayconstant.RelayModeRealtime { return channel.DoWssRequest(a, c, info, requestBody) diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index c5477cc..df26d4c 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -143,11 +143,11 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq imageRequest := &dto.ImageRequest{} switch relayMode { - case relayconstant.RelayModeImagesEdits: + case relayconstant.RelayModeImagesEdits, relayconstant.RelayModeImagesGenerations: if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { _, err := c.MultipartForm() if err != nil { - return nil, fmt.Errorf("failed to parse image edit form request: %w", err) + return nil, fmt.Errorf("failed to parse image form request: %w", err) } formData := c.Request.PostForm imageRequest.Prompt = formData.Get("prompt") diff --git a/router/api-router.go b/router/api-router.go index 8d4e992..a59cbaf 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -260,6 +260,19 @@ func SetApiRouter(router *gin.Engine) { tokenRoute.POST("/batch", controller.DeleteTokenBatch) } + imageStudioRoute := apiRouter.Group("/image-studio") + imageStudioRoute.Use(middleware.UserAuth()) + { + imageStudioRoute.POST("/generate", controller.SubmitImageStudioGenerate) + imageStudioRoute.POST("/edit", controller.SubmitImageStudioEdit) + imageStudioRoute.GET("/task/:id", controller.GetImageStudioTask) + } + imageStudioResultRoute := apiRouter.Group("/image-studio") + imageStudioResultRoute.Use(middleware.TokenOrUserAuth()) + { + imageStudioResultRoute.GET("/task/:id/result/:index", controller.GetImageStudioTaskResult) + } + usageRoute := apiRouter.Group("/usage") usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit()) { diff --git a/router/web-router.go b/router/web-router.go index 17a8378..5ae5a83 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -17,6 +17,7 @@ func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { router.Use(gzip.Gzip(gzip.DefaultCompression)) router.Use(middleware.GlobalWebRateLimit()) router.Use(middleware.Cache()) + router.GET("/image-studio-results/*filepath", controller.GetImageStudioResultFile) router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist"))) router.NoRoute(func(c *gin.Context) { c.Set(middleware.RouteTagKey, "web") diff --git a/setting/sensitive.go b/setting/sensitive.go index 86f9be9..a48024b 100644 --- a/setting/sensitive.go +++ b/setting/sensitive.go @@ -13,25 +13,136 @@ var StopOnSensitiveEnabled = true // StreamCacheQueueLength 流模式缓存队列长度,0表示无缓存 var StreamCacheQueueLength = 0 +const defaultImageStudioSafetyPrompt = `你是一个严格的图片生成安全审核器。 +如果用户提示词包含或试图引导生成以下内容,必须直接拒绝: +1. 暴力、血腥、伤害、虐待、自残、自杀、恐怖主义、战争、武器、尸体、残肢、令人极度恶心的内容。 +2. 色情、裸露、性暗示、卖淫、约炮、成人视频、强奷、SM、性暴力、性服务相关内容。 +3. 赌博、博彩、赌场、下注、下注网站、洗钱、毒品、制毒、吸毒、贩毒、违禁药物。 +4. 中国政治敏感、反动、分裂、颠覆、煽动、侮辱国家象征、攻击国家领导人、违法集会、暴恐宣传等内容。 +5. 任何试图绕过上述限制的变体、谐音、拆字、缩写、暗语、拼音、外语翻译或角色扮演指令。 + +如果命中任何一项,直接拒绝,不要改写,不要生成,不要解释如何绕过。` + +const defaultImageStudioBannedWords = `暴力 +血腥 +伤害 +虐待 +自残 +自杀 +恐怖 +恐怖主义 +战争 +武器 +枪支 +炸弹 +尸体 +残肢 +恶心 +色情 +裸露 +性暗示 +卖淫 +约炮 +成人视频 +SM +性暴力 +性服务 +赌博 +博彩 +赌场 +下注 +洗钱 +毒品 +制毒 +吸毒 +贩毒 +违禁药物 +政治敏感 +反动 +分裂 +颠覆 +煽动 +国家领导人 +国旗 +国徽 +国歌 +暴恐 +porn +nude +nudity +sex +sexual +erotic +nsfw +gore +bloody +blood +violence +violent +suicide +self-harm +terrorist +terrorism +gambling +casino +drug +drugs +cocaine +heroin +meth` + +var ImageStudioSafetyPrompt = strings.TrimSpace(defaultImageStudioSafetyPrompt) +var ImageStudioBannedWords = splitLines(defaultImageStudioBannedWords) + // SensitiveWords 敏感词 // var SensitiveWords []string var SensitiveWords = []string{ "test_sensitive", } +func splitLines(s string) []string { + items := make([]string, 0) + for _, item := range strings.Split(s, "\n") { + item = strings.TrimSpace(item) + if item != "" { + items = append(items, item) + } + } + return items +} + func SensitiveWordsToString() string { return strings.Join(SensitiveWords, "\n") } func SensitiveWordsFromString(s string) { - SensitiveWords = []string{} - sw := strings.Split(s, "\n") - for _, w := range sw { - w = strings.TrimSpace(w) - if w != "" { - SensitiveWords = append(SensitiveWords, w) - } + SensitiveWords = splitLines(s) +} + +func ImageStudioSafetyPromptToString() string { + return ImageStudioSafetyPrompt +} + +func ImageStudioSafetyPromptFromString(s string) { + prompt := strings.TrimSpace(s) + if prompt == "" { + ImageStudioSafetyPrompt = strings.TrimSpace(defaultImageStudioSafetyPrompt) + return } + ImageStudioSafetyPrompt = prompt +} + +func ImageStudioBannedWordsToString() string { + return strings.Join(ImageStudioBannedWords, "\n") +} + +func ImageStudioBannedWordsFromString(s string) { + words := splitLines(s) + if len(words) == 0 { + ImageStudioBannedWords = splitLines(defaultImageStudioBannedWords) + return + } + ImageStudioBannedWords = words } func ShouldCheckPromptSensitive() bool { diff --git a/web/src/App.jsx b/web/src/App.jsx index a5d1ebc..aaf4a58 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -47,6 +47,7 @@ import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; +import ImageStudio from './pages/ImageStudio'; import Setup from './pages/Setup'; import SetupCheck from './components/layout/SetupCheck'; @@ -277,6 +278,16 @@ function App() { } /> + + } key={location.pathname}> + + + + } + /> {} }) => { @@ -145,6 +146,18 @@ const SiderBar = ({ onNavigate = () => {} }) => { return filteredItems; }, [t, isModuleVisible]); + const otherItems = useMemo(() => { + const items = [ + { + text: t('图片制作'), + itemKey: 'image_studio', + to: '/image-studio', + }, + ]; + + return items.filter((item) => isModuleVisible('other', item.itemKey)); + }, [t, isModuleVisible]); + const adminItems = useMemo(() => { const items = [ { @@ -475,6 +488,19 @@ const SiderBar = ({ onNavigate = () => {} }) => { )} + {/* 其他功能区域 */} + {hasSectionVisibleModules('other') && ( + <> + +
+ {!collapsed && ( +
{t('其他功能')}
+ )} + {otherItems.map((item) => renderNavItem(item))} +
+ + )} + {/* 管理员区域 - 只在管理员时显示且配置允许时显示 */} {isAdmin() && hasSectionVisibleModules('admin') && ( <> diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 5e8d4fd..3474214 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -63,36 +63,9 @@ const NotificationSettings = ({ // 左侧边栏设置相关状态 const [sidebarLoading, setSidebarLoading] = useState(false); const [activeTabKey, setActiveTabKey] = useState('notification'); - const [sidebarModulesUser, setSidebarModulesUser] = useState({ - chat: { - enabled: true, - playground: true, - chat: true, - }, - console: { - enabled: true, - detail: true, - token: true, - log: true, - midjourney: true, - task: true, - }, - personal: { - enabled: true, - topup: true, - personal: true, - }, - admin: { - enabled: true, - channel: true, - models: true, - deployment: true, - subscription: true, - redemption: true, - user: true, - setting: true, - }, - }); + const [sidebarModulesUser, setSidebarModulesUser] = useState( + mergeAdminConfig(null), + ); const [adminConfig, setAdminConfig] = useState(null); // 使用后端权限验证替代前端角色判断 @@ -155,29 +128,7 @@ const NotificationSettings = ({ }; const resetSidebarModules = () => { - const defaultConfig = { - chat: { enabled: true, playground: true, chat: true }, - console: { - enabled: true, - detail: true, - token: true, - log: true, - midjourney: true, - task: true, - }, - personal: { enabled: true, topup: true, personal: true }, - admin: { - enabled: true, - channel: true, - models: true, - deployment: true, - subscription: true, - redemption: true, - user: true, - setting: true, - }, - }; - setSidebarModulesUser(defaultConfig); + setSidebarModulesUser(mergeAdminConfig(null)); }; // 加载左侧边栏配置 @@ -207,7 +158,7 @@ const NotificationSettings = ({ } else { userConf = userRes.data.data.sidebar_modules; } - setSidebarModulesUser(userConf); + setSidebarModulesUser(mergeAdminConfig(userConf)); } } catch (error) { console.error('加载边栏配置失败:', error); @@ -287,6 +238,18 @@ const NotificationSettings = ({ ], }, // 管理员区域:根据后端权限控制显示 + { + key: 'other', + title: t('其他功能区域'), + description: t('图片与扩展功能'), + modules: [ + { + key: 'image_studio', + title: t('图片制作'), + description: t('生成图片和编辑图片'), + }, + ], + }, { key: 'admin', title: t('管理员区域'), diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 7c0ea0e..e2bce61 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -75,6 +75,7 @@ import { Package, Server, CalendarClock, + Sparkles, } from 'lucide-react'; import { SiAtlassian, @@ -146,6 +147,8 @@ export function getLucideIcon(key, selected = false) { return ; case 'setting': return ; + case 'image_studio': + return ; default: return ; } diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index 435a11e..3757f9d 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -121,13 +121,12 @@ if (isMobileScreen) { export function showError(error) { console.error(error); - if (error.message) { + if (error?.message) { if (error.name === 'AxiosError') { - switch (error.response.status) { + const status = error.response?.status; + switch (status) { case 401: - // 清除用户状态 localStorage.removeItem('user'); - // toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions); window.location.href = '/login?expired=true'; break; case 429: diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index cd74ada..bf33a66 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -44,6 +44,10 @@ export const DEFAULT_ADMIN_CONFIG = { topup: true, personal: true, }, + other: { + enabled: true, + image_studio: true, + }, admin: { enabled: true, channel: true, diff --git a/web/src/pages/ImageStudio/index.jsx b/web/src/pages/ImageStudio/index.jsx new file mode 100644 index 0000000..6cec66f --- /dev/null +++ b/web/src/pages/ImageStudio/index.jsx @@ -0,0 +1,1310 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Banner, + Button, + Card, + Empty, + Modal, + Select, + Space, + TabPane, + Tabs, + Tag, + TextArea, + Typography, +} from '@douyinfe/semi-ui'; +import { + ExternalLink, + ImagePlus, + Sparkles, + Trash2, + Upload, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { API, showError, showSuccess } from '../../helpers'; + +const { Text, Title } = Typography; + +const IMAGE_GROUP = 'image'; +const PROMPT_REFERENCE_URL = + 'https://github.com/EvoLinkAI/awesome-gpt-image-2-prompts'; + +const IMAGE_TASK_POLL_INTERVAL = 3000; +const IMAGE_TASK_MAX_POLL_COUNT = 120; + +const SIZE_OPTIONS = [ + { + value: 'auto', + ratioLabel: 'Auto', + resolutionLabel: 'Auto', + usage: '让模型自动判断', + previewAspect: '1 / 1', + }, + { + value: '1024x1024', + ratioLabel: '1:1', + resolutionLabel: '1024x1024', + usage: '1K 方图 / 头像 / 社交封面', + previewAspect: '1 / 1', + }, + { + value: '1536x1024', + ratioLabel: '3:2', + resolutionLabel: '1536x1024', + usage: '1.5K 横图 / 文章封面 / 横幅图', + previewAspect: '3 / 2', + }, + { + value: '1024x1536', + ratioLabel: '2:3', + resolutionLabel: '1024x1536', + usage: '1.5K 竖图 / 手机壁纸 / 故事封面', + previewAspect: '2 / 3', + }, + { + value: '2048x2048', + ratioLabel: '1:1', + resolutionLabel: '2048x2048', + usage: '2K 方图 / 海报精修 / 高清详情图', + previewAspect: '1 / 1', + }, + { + value: '2048x1152', + ratioLabel: '16:9', + resolutionLabel: '2048x1152', + usage: '2K 横图 / 演示封面 / 宽屏头图', + previewAspect: '16 / 9', + }, + { + value: '1152x2048', + ratioLabel: '9:16', + resolutionLabel: '1152x2048', + usage: '2K 竖图 / 短视频封面 / 竖屏海报', + previewAspect: '9 / 16', + }, + { + value: '3840x2160', + ratioLabel: '16:9', + resolutionLabel: '3840x2160', + usage: '4K 横图 / 桌面壁纸 / 大屏展示', + previewAspect: '16 / 9', + }, + { + value: '2160x3840', + ratioLabel: '9:16', + resolutionLabel: '2160x3840', + usage: '4K 竖图 / 手机壁纸 / 竖屏大图', + previewAspect: '9 / 16', + }, +]; + +const QUALITY_OPTIONS = [ + { value: 'auto', label: 'Auto', description: '默认,由模型自动决定质量' }, + { value: 'low', label: 'Low', description: '更快,更省' }, + { value: 'medium', label: 'Medium', description: '平衡速度与质量' }, + { value: 'high', label: 'High', description: '更高质量,通常更慢' }, +]; + +const sortModels = (models = []) => { + return [...models].sort((a, b) => { + if (a.model_name === 'gpt-image-2') return -1; + if (b.model_name === 'gpt-image-2') return 1; + return String(a.model_name || '').localeCompare(String(b.model_name || '')); + }); +}; + +const parseTokenModelLimits = (token) => { + if (!token?.model_limits_enabled || !token?.model_limits) { + return []; + } + + if (Array.isArray(token.model_limits)) { + return token.model_limits.map((item) => String(item).trim()).filter(Boolean); + } + + return String(token.model_limits) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +}; + +const supportsEndpoint = (model, endpointType) => { + return ( + Array.isArray(model?.supported_endpoint_types) && + model.supported_endpoint_types.includes(endpointType) + ); +}; + +const getSizeMeta = (size) => { + return SIZE_OPTIONS.find((item) => item.value === size) || SIZE_OPTIONS[0]; +}; + +const getImageMimeType = (item) => { + const format = String(item?.output_format || '').trim().toLowerCase(); + if (format === 'jpeg' || format === 'jpg') { + return 'image/jpeg'; + } + if (format === 'webp') { + return 'image/webp'; + } + if (typeof item?.mime_type === 'string' && item.mime_type.startsWith('image/')) { + return item.mime_type; + } + return 'image/png'; +}; + +const getImageExtension = (item) => { + const mimeType = getImageMimeType(item); + if (mimeType === 'image/jpeg') { + return 'jpg'; + } + if (mimeType === 'image/webp') { + return 'webp'; + } + return 'png'; +}; + +const buildImageSrc = (item, nextResultUrls) => { + if (item?.b64_json) { + const mimeType = getImageMimeType(item); + const binary = window.atob(item.b64_json); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + const objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType })); + nextResultUrls.push(objectUrl); + return objectUrl; + } + return item?.url || ''; +}; + +const normalizeImageResults = (payload) => { + const data = Array.isArray(payload?.data) ? payload.data : []; + const urls = []; + const items = data + .map((item, index) => ({ + id: `${Date.now()}-${index}`, + src: buildImageSrc(item, urls), + revisedPrompt: item?.revised_prompt || '', + downloadName: `gpt-image-${index + 1}.${getImageExtension(item)}`, + })) + .filter((item) => item.src); + + return { items, urls }; +}; + +const buildTokenLabel = (token) => { + const name = token?.name ? String(token.name).trim() : ''; + if (name) { + return name; + } + return '未命名令牌'; +}; + +const ImageStudio = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const resultRef = useRef(null); + const uploadInputRef = useRef(null); + const referenceImageUrlsRef = useRef([]); + const resultImageUrlsRef = useRef([]); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [activeTab, setActiveTab] = useState('generate'); + const [tokens, setTokens] = useState([]); + const [pricingModels, setPricingModels] = useState([]); + const [selectedTokenId, setSelectedTokenId] = useState(''); + const [selectedModel, setSelectedModel] = useState(''); + const [prompt, setPrompt] = useState(''); + const [selectedSize, setSelectedSize] = useState('auto'); + const [selectedQuality, setSelectedQuality] = useState('auto'); + const [referenceImages, setReferenceImages] = useState([]); + const [results, setResults] = useState([]); + const [resultMeta, setResultMeta] = useState(null); + const [pendingTask, setPendingTask] = useState(null); + const [previewModal, setPreviewModal] = useState({ + visible: false, + src: '', + title: '', + }); + + const clearResultImageUrls = () => { + resultImageUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + resultImageUrlsRef.current = []; + }; + + useEffect(() => { + return () => { + referenceImageUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + referenceImageUrlsRef.current = []; + clearResultImageUrls(); + }; + }, []); + + useEffect(() => { + const loadData = async () => { + setLoading(true); + try { + const [tokensRes, pricingRes] = await Promise.all([ + API.get('/api/token/?p=1&size=1000'), + API.get('/api/pricing'), + ]); + + const tokenPayload = tokensRes?.data?.data; + const tokenItems = Array.isArray(tokenPayload) + ? tokenPayload + : tokenPayload?.items || []; + const imageTokens = tokenItems.filter( + (token) => token?.status === 1 && token?.group === IMAGE_GROUP, + ); + setTokens(imageTokens); + + const pricingPayload = Array.isArray(pricingRes?.data?.data) + ? pricingRes.data.data + : []; + setPricingModels(pricingPayload); + } catch (error) { + showError(t('加载图片制作配置失败,请稍后重试')); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [t]); + + const imageGroupModels = useMemo(() => { + return pricingModels.filter( + (model) => + Array.isArray(model?.enable_groups) && + model.enable_groups.includes(IMAGE_GROUP), + ); + }, [pricingModels]); + + const endpointType = activeTab === 'edit' ? 'image-edit' : 'image-generation'; + + const endpointModels = useMemo(() => { + return imageGroupModels.filter((model) => supportsEndpoint(model, endpointType)); + }, [endpointType, imageGroupModels]); + + const selectedToken = useMemo(() => { + return tokens.find((token) => String(token.id) === String(selectedTokenId)); + }, [selectedTokenId, tokens]); + + const availableModels = useMemo(() => { + if (!selectedToken) { + return []; + } + + const tokenModelLimits = parseTokenModelLimits(selectedToken); + const matched = + selectedToken.model_limits_enabled && tokenModelLimits.length > 0 + ? endpointModels.filter((model) => tokenModelLimits.includes(model.model_name)) + : endpointModels; + + return sortModels(matched); + }, [endpointModels, selectedToken]); + + useEffect(() => { + if (!tokens.length) { + if (selectedTokenId) { + setSelectedTokenId(''); + } + return; + } + + const hasCurrentToken = tokens.some( + (token) => String(token.id) === String(selectedTokenId), + ); + + if (!hasCurrentToken) { + setSelectedTokenId(String(tokens[0].id)); + } + }, [selectedTokenId, tokens]); + + useEffect(() => { + const hasCurrentModel = availableModels.some( + (model) => model.model_name === selectedModel, + ); + if (hasCurrentModel) { + return; + } + + const preferredModel = + availableModels.find((model) => model.model_name === 'gpt-image-2') || + availableModels[0]; + + setSelectedModel(preferredModel?.model_name || ''); + }, [availableModels, selectedModel]); + + const tokenOptions = useMemo(() => { + return tokens.map((token) => ({ + label: buildTokenLabel(token), + value: String(token.id), + })); + }, [tokens]); + + const modelOptions = useMemo(() => { + return availableModels.map((model) => ({ + label: model.model_name, + value: model.model_name, + })); + }, [availableModels]); + + const sizeRatioOptions = useMemo(() => { + return SIZE_OPTIONS.map((item) => ({ + label: + item.value === 'auto' + ? 'Auto' + : `${item.ratioLabel} · ${item.usage}`, + value: item.value, + })); + }, []); + + const sizeResolutionOptions = useMemo(() => { + return SIZE_OPTIONS.map((item) => ({ + label: + item.value === 'auto' + ? 'Auto(默认)' + : `${item.resolutionLabel} · 官方预设`, + value: item.value, + })); + }, []); + + const qualityOptions = useMemo(() => { + return QUALITY_OPTIONS.map((item) => ({ + label: `${item.label} · ${item.description}`, + value: item.value, + })); + }, []); + + const sizeMeta = getSizeMeta(selectedSize); + const tokenModelLimits = parseTokenModelLimits(selectedToken); + + const noImageTokens = !loading && tokens.length === 0; + const noImageModels = !loading && imageGroupModels.length === 0; + const noEndpointModels = !noImageModels && endpointModels.length === 0; + const noModelsForToken = + !noImageModels && + !noEndpointModels && + !!selectedToken && + availableModels.length === 0; + + const canSubmit = + !submitting && + !!selectedTokenId && + !!selectedModel && + prompt.trim().length > 0 && + (activeTab !== 'edit' || referenceImages.length > 0); + + const openPromptReference = () => { + window.open(PROMPT_REFERENCE_URL, '_blank', 'noopener,noreferrer'); + }; + + const openPreviewModal = (src, title) => { + if (!src) { + return; + } + setPreviewModal({ + visible: true, + src, + title: title || t('图片预览'), + }); + }; + + const closePreviewModal = () => { + setPreviewModal({ + visible: false, + src: '', + title: '', + }); + }; + + const triggerReferenceUpload = () => { + if (submitting) { + return; + } + uploadInputRef.current?.click(); + }; + + const handleTabChange = (key) => { + if (submitting) { + return; + } + setActiveTab(key); + }; + + const handleReferenceUpload = (event) => { + const files = Array.from(event.target.files || []); + if (!files.length) { + return; + } + + const validFiles = files.filter((file) => String(file.type || '').startsWith('image/')); + const invalidCount = files.length - validFiles.length; + if (invalidCount > 0) { + showError(t('只能上传图片文件,请重新选择')); + } + if (!validFiles.length) { + event.target.value = ''; + return; + } + + const nextItems = validFiles.map((file, index) => { + const url = URL.createObjectURL(file); + referenceImageUrlsRef.current.push(url); + return { + id: `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`, + file, + url, + }; + }); + + setReferenceImages((prev) => [...prev, ...nextItems]); + event.target.value = ''; + }; + + const removeReferenceImage = (imageId) => { + setReferenceImages((prev) => { + const current = prev.find((item) => item.id === imageId); + if (current?.url) { + URL.revokeObjectURL(current.url); + referenceImageUrlsRef.current = referenceImageUrlsRef.current.filter((url) => url !== current.url); + } + return prev.filter((item) => item.id !== imageId); + }); + }; + + const normalizeErrorMessage = (error) => { + if (error?.message) { + return error.message; + } + if (error?.name === 'AxiosError' && !error?.response) { + return t('图片结果返回过大或网络中断,请稍后重试;如反复出现,可刷新页面后在结果区重试查看。'); + } + return t('图片请求失败,请稍后重试'); + }; + + const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + + const submitGenerateTask = async () => { + let response; + if (referenceImages.length > 0) { + const formData = new FormData(); + formData.append('token_id', String(selectedTokenId)); + formData.append('model', selectedModel); + formData.append('prompt', prompt.trim()); + formData.append('size', selectedSize); + formData.append('quality', selectedQuality); + + referenceImages.forEach((item) => { + formData.append('image[]', item.file, item.file.name); + }); + + response = await API.post('/api/image-studio/generate', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + } else { + response = await API.post('/api/image-studio/generate', { + token_id: Number(selectedTokenId), + model: selectedModel, + prompt: prompt.trim(), + size: selectedSize, + quality: selectedQuality, + }); + } + const payload = response?.data || {}; + if (!payload?.success) { + throw new Error(payload?.message || '图片生成任务提交失败'); + } + return payload.data || null; + }; + + const submitEditTask = async () => { + const formData = new FormData(); + formData.append('token_id', String(selectedTokenId)); + formData.append('model', selectedModel); + formData.append('prompt', prompt.trim()); + formData.append('size', selectedSize); + formData.append('quality', selectedQuality); + + referenceImages.forEach((item) => { + formData.append('image[]', item.file, item.file.name); + }); + + const response = await API.post('/api/image-studio/edit', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + const payload = response?.data || {}; + if (!payload?.success) { + throw new Error(payload?.message || '图片编辑任务提交失败'); + } + return payload.data || null; + }; + + const pollImageTask = async (taskId) => { + for (let attempt = 0; attempt < IMAGE_TASK_MAX_POLL_COUNT; attempt += 1) { + let response; + try { + response = await API.get(`/api/image-studio/task/${taskId}`); + } catch (error) { + if (error?.name === 'AxiosError' && !error?.response) { + throw new Error(t('图片结果返回过大或网络中断,请稍后重试;任务通常仍在服务器端继续执行。')); + } + throw error; + } + + const payload = response?.data || {}; + if (!payload?.success) { + throw new Error(payload?.message || '图片任务状态查询失败'); + } + + const task = payload.data || {}; + if (task.status === 'success') { + return task; + } + if (task.status === 'failure') { + throw new Error(task.error || '图片任务处理失败'); + } + + await sleep(IMAGE_TASK_POLL_INTERVAL); + } + + throw new Error('图片任务仍在处理中,请稍后重试,或保持页面等待结果返回'); + }; + + const handleSubmit = async () => { + if (!selectedTokenId) { + showError(t('请先选择 image 分组令牌')); + return; + } + if (!selectedModel) { + showError( + noModelsForToken + ? t('当前令牌没有可用图片模型,请去控制台-令牌管理调整模型限制') + : t('请先选择模型'), + ); + return; + } + if (!prompt.trim()) { + showError(t('请先填写提示词')); + return; + } + if (activeTab === 'edit' && referenceImages.length === 0) { + showError(t('编辑图片至少需要上传一张参考图')); + return; + } + + setSubmitting(true); + setPendingTask(null); + let nextResultPayload = null; + try { + const submittedTask = + activeTab === 'edit' + ? await submitEditTask() + : await submitGenerateTask(); + + if (!submittedTask?.task_id) { + throw new Error('图片任务提交失败,请稍后重试'); + } + + setPendingTask(submittedTask); + showSuccess( + activeTab === 'edit' + ? t('图片编辑任务已提交,正在后台处理') + : referenceImages.length > 0 + ? t('参考图生成任务已提交,正在后台处理') + : t('图片生成任务已提交,正在后台处理'), + ); + + const finishedTask = await pollImageTask(submittedTask.task_id); + nextResultPayload = normalizeImageResults(finishedTask.result || {}); + + if (!nextResultPayload.items.length) { + throw new Error('接口未返回可展示的图片结果'); + } + + clearResultImageUrls(); + resultImageUrlsRef.current = nextResultPayload.urls; + setResults(nextResultPayload.items); + nextResultPayload = null; + setResultMeta({ + mode: activeTab, + model: selectedModel, + size: selectedSize, + quality: selectedQuality, + taskId: finishedTask.task_id, + createdAt: new Date().toLocaleString(), + }); + setPendingTask(null); + + showSuccess( + activeTab === 'edit' + ? t('图片编辑完成') + : referenceImages.length > 0 + ? t('参考图生成完成') + : t('图片生成完成'), + ); + window.requestAnimationFrame(() => { + resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } catch (error) { + if (nextResultPayload?.urls?.length) { + nextResultPayload.urls.forEach((url) => URL.revokeObjectURL(url)); + } + showError(normalizeErrorMessage(error)); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ +
+
+
+ + + {t('图片制作')} + +
+ + {t('基于 image 分组令牌与模型,支持生成图片和编辑图片。')} + +
+ + {t('模型优先默认 gpt-image-2')} + {t('结果为临时图片,约 30 分钟后自动删除')} + +
+
+ + {noImageTokens && ( + 令牌管理 -> 添加令牌,创建并选择 image 分组;创建完成后刷新此页面即可。', + )} + /> + )} + + {!noImageTokens && noImageModels && ( + + )} + + {!noImageTokens && !noImageModels && noEndpointModels && ( + + )} + + {!noImageTokens && !noImageModels && !noEndpointModels && noModelsForToken && ( + 令牌管理 调整此令牌的模型限制,或重新创建一个 image 分组令牌。', + )} + /> + )} + +
+ + + +
+
+
+ {t('令牌')} + setSelectedModel(String(value || ''))} + optionList={modelOptions} + placeholder={t('请选择图片模型')} + disabled={loading || submitting || availableModels.length === 0} + style={{ width: '100%' }} + /> + + {selectedToken?.model_limits_enabled && tokenModelLimits.length > 0 + ? t('当前令牌启用了模型限制,列表只展示此令牌允许的图片模型。') + : t('列表只展示 image 分组中已配置、且当前模式可用的模型。')} + +
+
+ +
+
+ {t('提示词')} + +
+