From 92ee465a41410b37bfdd7a0e6499f5edeab394d1 Mon Sep 17 00:00:00 2001 From: OpenClaw Task Bot Date: Tue, 5 May 2026 12:16:54 +0800 Subject: [PATCH] Expose image studio flow and related admin controls Bundle the current backend and web changes into one publishable snapshot from the server worktree so the remote mirror reflects the deployed source. Constraint: Push the existing mixed worktree from /docker/new-api/src without reshaping files Rejected: Split into feature-specific commits | current worktree is already a single undifferentiated snapshot Confidence: medium Scope-risk: moderate Directive: Split future feature work before deployment to preserve clearer history Tested: git status inspection; git diff --stat review Not-tested: build, lint, unit tests, integration tests --- Dockerfile | 2 +- Dockerfile.bak.20260326 | 38 - common/endpoint_type.go | 4 +- common/model.go | 19 + controller/image_studio.go | 1087 ++++++++++++++ controller/secure_verification.go | 16 +- controller/user.go | 6 + middleware/distributor.go | 8 +- model/option.go | 6 + relay/channel/openai/adaptor.go | 8 +- relay/helper/valid_request.go | 4 +- router/api-router.go | 13 + router/web-router.go | 1 + setting/sensitive.go | 125 +- web/src/App.jsx | 11 + web/src/components/layout/SiderBar.jsx | 26 + .../personal/cards/NotificationSettings.jsx | 71 +- web/src/helpers/render.jsx | 3 + web/src/helpers/utils.jsx | 7 +- web/src/hooks/common/useSidebar.js | 4 + web/src/pages/ImageStudio/index.jsx | 1310 +++++++++++++++++ .../Operation/SettingsSensitiveWords.jsx | 23 +- .../Operation/SettingsSidebarModulesAdmin.jsx | 104 +- 23 files changed, 2694 insertions(+), 202 deletions(-) delete mode 100644 Dockerfile.bak.20260326 create mode 100644 controller/image_studio.go create mode 100644 web/src/pages/ImageStudio/index.jsx 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('提示词')} + +
+