feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
126
backend/internal/protocol/cangyuan_midjourney_test.go
Normal file
126
backend/internal/protocol/cangyuan_midjourney_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func cangyuanAdapter(t *testing.T, provider string) Adapter {
|
||||
t.Helper()
|
||||
return officialPackageAdapter(t, "cangyuan-midjourney.yingce-plugin", provider)
|
||||
}
|
||||
|
||||
func TestCangyuanMidjourneyV7RequestAndEditPolling(t *testing.T) {
|
||||
adapter := cangyuanAdapter(t, "cangyuan-midjourney-v7")
|
||||
request := GenerationRequest{Model: "midjourney-v7", Prompt: "生成一张氛围感美女照片", ImageCount: 1, AspectRatio: "auto", Quality: "high", Resolution: "2k", Extra: map[string]any{"transparentBackground": "true"}}
|
||||
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := manifestTestBody(t, spec)
|
||||
if spec.Path != "/v1/images/generations" || body["n"] != float64(1) || body["response_format"] != "url" || body["async"] != false {
|
||||
t.Fatalf("create = %#v, body = %#v", spec, body)
|
||||
}
|
||||
for _, key := range []string{"size", "images", "quality", "resolution", "background", "speed", "output_format", "mask"} {
|
||||
if _, ok := body[key]; ok {
|
||||
t.Fatalf("unsupported field %s was emitted: %#v", key, body)
|
||||
}
|
||||
}
|
||||
request.Images = []MediaReference{{URL: "https://example.com/second.png", Role: "edit_source", Order: 2}, {URL: "https://example.com/first.png", Role: "edit_source", Order: 1}}
|
||||
request.AspectRatio = "9:16"
|
||||
spec, err = adapter.BuildCreate(context.Background(), RequestContext{Request: request})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = manifestTestBody(t, spec)
|
||||
images := body["images"].([]any)
|
||||
if spec.Path != "/v1/images/edits" || body["size"] != "9:16" || len(images) != 2 || images[0] != "https://example.com/first.png" {
|
||||
t.Fatalf("edit = %#v, body = %#v", spec, body)
|
||||
}
|
||||
poll, err := adapter.BuildPoll(context.Background(), PollContext{Request: request, TaskID: "task-42"})
|
||||
if err != nil || poll.Path != "/v1/images/edits/task-42" {
|
||||
t.Fatalf("edit poll = %#v, err = %v", poll, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCangyuanMidjourneyV82VersionsReferencesAndMask(t *testing.T) {
|
||||
adapter := cangyuanAdapter(t, "cangyuan-midjourney-v82")
|
||||
for _, model := range []string{"midjourney-1k", "midjourney-2k"} {
|
||||
request := GenerationRequest{Model: model, Prompt: "portrait --v 8.2", ImageCount: 1, Quality: "high"}
|
||||
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := manifestTestBody(t, spec)
|
||||
if body["prompt"] != request.Prompt || body["model"] != model {
|
||||
t.Fatalf("version request = %#v", body)
|
||||
}
|
||||
for _, key := range []string{"quality", "resolution", "background", "output_format", "speed", "reference", "mask"} {
|
||||
if _, ok := body[key]; ok {
|
||||
t.Fatalf("unexpected %s in %#v", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
request := GenerationRequest{Model: "midjourney-1k", Prompt: "portrait --v 8.2", Images: []MediaReference{{URL: "https://example.com/source.png", Role: "edit_source"}, {URL: "https://example.com/mask.png", Role: "mask"}}}
|
||||
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := manifestTestBody(t, spec)
|
||||
if spec.Path != "/v1/images/edits" || body["reference"] != "editor" || body["mask"] != "https://example.com/mask.png" || len(body["images"].([]any)) != 1 {
|
||||
t.Fatalf("masked edit = %#v, body = %#v", spec, body)
|
||||
}
|
||||
request.Images = request.Images[:1]
|
||||
request.ProviderOptions = map[string]map[string]any{"cangyuan-midjourney-v82": {"reference": "style", "speed": "fast"}}
|
||||
spec, err = adapter.BuildCreate(context.Background(), RequestContext{Request: request})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = manifestTestBody(t, spec)
|
||||
if spec.Path != "/v1/images/generations" || body["reference"] != "style" || body["speed"] != "fast" {
|
||||
t.Fatalf("style = %#v, body = %#v", spec, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCangyuanMidjourneyRejectsInvalidInputs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, provider string
|
||||
request GenerationRequest
|
||||
}{
|
||||
{"model mismatch", "cangyuan-midjourney-v7", GenerationRequest{Model: "midjourney-1k", Prompt: "portrait"}},
|
||||
{"invented v82 model", "cangyuan-midjourney-v82", GenerationRequest{Model: "midjourney-v8.2", Prompt: "portrait"}},
|
||||
{"multiple submissions", "cangyuan-midjourney-v7", GenerationRequest{Model: "midjourney-v7", Prompt: "portrait", ImageCount: 2}},
|
||||
{"long unicode prompt", "cangyuan-midjourney-v7", GenerationRequest{Model: "midjourney-v7", Prompt: strings.Repeat("画", 4001)}},
|
||||
{"data URI", "cangyuan-midjourney-v7", GenerationRequest{Model: "midjourney-v7", Prompt: "portrait", Images: []MediaReference{{DataURL: "data:image/png;base64,aA=="}}}},
|
||||
{"v7 mask", "cangyuan-midjourney-v7", GenerationRequest{Model: "midjourney-v7", Prompt: "portrait", Images: []MediaReference{{URL: "https://example.com/mask.png", Role: "mask"}}}},
|
||||
{"editor missing source", "cangyuan-midjourney-v82", GenerationRequest{Model: "midjourney-1k", Prompt: "portrait", Images: []MediaReference{{URL: "https://example.com/mask.png", Role: "mask"}}}},
|
||||
{"invalid speed", "cangyuan-midjourney-v82", GenerationRequest{Model: "midjourney-1k", Prompt: "portrait", ProviderOptions: map[string]map[string]any{"cangyuan-midjourney-v82": {"speed": "turbo"}}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := cangyuanAdapter(t, tc.provider).BuildCreate(context.Background(), RequestContext{Request: tc.request}); err == nil {
|
||||
t.Fatal("invalid input was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCangyuanMidjourneyPreservesAllResultsAndTaskStates(t *testing.T) {
|
||||
for _, provider := range []string{"cangyuan-midjourney-v7", "cangyuan-midjourney-v82"} {
|
||||
adapter := cangyuanAdapter(t, provider)
|
||||
for _, payload := range []string{`{"data":[{"url":"https://example.com/1.png"},{"url":"https://example.com/2.png"},{"url":"https://example.com/3.png"},{"url":"https://example.com/4.png"}]}`, `{"id":"task-42","status":"completed","data":[{"url":"https://example.com/1.png"},{"url":"https://example.com/2.png"},{"url":"https://example.com/3.png"},{"url":"https://example.com/4.png"}]}`} {
|
||||
result, err := adapter.ParseCreate(context.Background(), []byte(payload))
|
||||
if err != nil || result.Status != StatusSucceeded || result.Result == nil || len(result.Result.Images) != 4 {
|
||||
t.Fatalf("%s result = %#v, err = %v", provider, result, err)
|
||||
}
|
||||
}
|
||||
pending, err := adapter.ParseCreate(context.Background(), []byte(`{"id":"task-42","status":"queued"}`))
|
||||
if err != nil || pending.TaskID != "task-42" || pending.Status != StatusPending {
|
||||
t.Fatalf("pending = %#v, err = %v", pending, err)
|
||||
}
|
||||
failed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "task-42"}, []byte(`{"status":"failed","error":{"code":"invalid_prompt","message":"prompt rejected"}}`))
|
||||
if err != nil || failed.TaskID != "task-42" || failed.Status != StatusFailed || failed.Message != "prompt rejected" {
|
||||
t.Fatalf("failure = %#v, err = %v", failed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
117
backend/internal/protocol/kacang_midjourney_test.go
Normal file
117
backend/internal/protocol/kacang_midjourney_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func kacangAdapter(t *testing.T, provider string) Adapter {
|
||||
t.Helper()
|
||||
return officialPackageAdapter(t, "kacang-midjourney.yingce-plugin", provider)
|
||||
}
|
||||
|
||||
func TestKacangMidjourneyRouteDefaultsAndReferenceOrder(t *testing.T) {
|
||||
for _, tc := range []struct{ provider, model, path, ratio string }{
|
||||
{"kacang-midjourney-special", "Midjourney v8.2-特惠", "/v1/images/generations", "16:9"},
|
||||
{"kacang-midjourney-v7", "Midjourney v7-特惠", "/v1/images/generations", "16:9"},
|
||||
{"kacang-midjourney", "mj-v8.2", "/v1/midjourney/generations", "9:16"},
|
||||
{"kacang-midjourney", "Midjourney v8.2 高速", "/v1/midjourney/generations", "9:16"},
|
||||
} {
|
||||
request := GenerationRequest{Model: tc.model, Prompt: "生成一张氛围感美女照片", Quality: "1k", Resolution: "4k"}
|
||||
adapter := kacangAdapter(t, tc.provider)
|
||||
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := manifestTestBody(t, spec)
|
||||
if spec.Path != tc.path || body["size"] != tc.ratio || body["n"] != float64(1) || body["prompt"] != request.Prompt {
|
||||
t.Fatalf("%s request = %#v", tc.model, body)
|
||||
}
|
||||
for _, key := range []string{"images", "resolution", "response_format", "async", "background", "output_format"} {
|
||||
if _, exists := body[key]; exists {
|
||||
t.Fatalf("unsupported %s emitted: %#v", key, body)
|
||||
}
|
||||
}
|
||||
if tc.model == "mj-v8.2" {
|
||||
if body["quality"] != float64(1) || body["stylize"] != float64(100) || body["chaos"] != float64(0) || body["raw"] != false || body["hd"] != false {
|
||||
t.Fatalf("stable defaults = %#v", body)
|
||||
}
|
||||
} else if _, exists := body["quality"]; exists {
|
||||
t.Fatalf("unsupported quality emitted: %#v", body)
|
||||
}
|
||||
if tc.provider != "kacang-midjourney" {
|
||||
continue
|
||||
}
|
||||
request.Images = []MediaReference{{URL: "https://example.com/b.png", Order: 2}, {URL: "https://example.com/a.png", Order: 1}}
|
||||
spec, err = adapter.BuildCreate(context.Background(), RequestContext{Request: request})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
urls := manifestTestBody(t, spec)["images"].([]any)
|
||||
if len(urls) != 2 || urls[0] != "https://example.com/a.png" {
|
||||
t.Fatalf("ordered references = %#v", urls)
|
||||
}
|
||||
poll, err := adapter.BuildPoll(context.Background(), PollContext{Request: request, TaskID: "task-42"})
|
||||
if err != nil || poll.Path != "/v1/tasks/task-42" {
|
||||
t.Fatalf("poll = %#v, error=%v", poll, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKacangMidjourneyRejectsWrongRouteAndUnsupportedInputs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, provider string
|
||||
request GenerationRequest
|
||||
}{
|
||||
{"wrong supplier", "kacang-midjourney", GenerationRequest{Model: "midjourney-1k", Prompt: "portrait"}},
|
||||
{"wrong class", "kacang-midjourney-special", GenerationRequest{Model: "mj-v8.2", Prompt: "portrait"}},
|
||||
{"blank prompt", "kacang-midjourney", GenerationRequest{Model: "mj-v8.2", Prompt: " "}},
|
||||
{"multiple submits", "kacang-midjourney", GenerationRequest{Model: "mj-v8.2", Prompt: "portrait", ImageCount: 4}},
|
||||
{"special auto", "kacang-midjourney-special", GenerationRequest{Model: "Midjourney v8.2-特惠", Prompt: "portrait", AspectRatio: "auto"}},
|
||||
{"pixel size", "kacang-midjourney", GenerationRequest{Model: "mj-v8.2", Prompt: "portrait", AspectRatio: "1024x1024"}},
|
||||
{"two special references", "kacang-midjourney-special", GenerationRequest{Model: "Midjourney v8.2-特惠", Prompt: "portrait", Images: []MediaReference{{URL: "https://example.com/a.png"}, {URL: "https://example.com/b.png"}}}},
|
||||
{"mask", "kacang-midjourney", GenerationRequest{Model: "mj-v8.2", Prompt: "portrait", Images: []MediaReference{{URL: "https://example.com/mask.png", Role: "mask"}}}},
|
||||
{"numeric quality range", "kacang-midjourney", GenerationRequest{Model: "mj-v8.2", Prompt: "portrait", ProviderOptions: map[string]map[string]any{"kacang-midjourney": {"quality": 3}}}},
|
||||
{"stylize range", "kacang-midjourney", GenerationRequest{Model: "mj-v8.2", Prompt: "portrait", ProviderOptions: map[string]map[string]any{"kacang-midjourney": {"stylize": 1001}}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := kacangAdapter(t, tc.provider).BuildCreate(context.Background(), RequestContext{Request: tc.request}); err == nil {
|
||||
t.Fatal("invalid request accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKacangMidjourneyPendingFailureAndAllPictures(t *testing.T) {
|
||||
adapter := kacangAdapter(t, "kacang-midjourney")
|
||||
pending, err := adapter.ParseCreate(context.Background(), []byte(`{"data":{"task_id":"task-42","status":"submitted"}}`))
|
||||
if err != nil || pending.TaskID != "task-42" || pending.Status != StatusPending || pending.Result != nil {
|
||||
t.Fatalf("accepted task = %#v, %v", pending, err)
|
||||
}
|
||||
for _, payload := range []string{
|
||||
`{"data":{"status":"completed","result":{"data":{"image_urls":["https://example.com/1.png","https://example.com/2.png","https://example.com/3.png","https://example.com/4.png"],"grid_image_url":"https://example.com/grid.png"}}}}`,
|
||||
`{"status":"completed","result":{"data":{"image_urls":["https://example.com/1.png","https://example.com/2.png","https://example.com/3.png","https://example.com/4.png","https://example.com/grid.png"],"grid_image_url":"https://example.com/grid.png"}}}`,
|
||||
} {
|
||||
result, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "task-42"}, []byte(payload))
|
||||
if err != nil || result.TaskID != "task-42" || result.Status != StatusSucceeded || result.Result == nil || len(result.Result.Images) != 5 {
|
||||
t.Fatalf("all images = %#v, %v", result, err)
|
||||
}
|
||||
for _, image := range result.Result.Images {
|
||||
if !image.Ephemeral {
|
||||
t.Fatal("temporary result was not marked for persistence")
|
||||
}
|
||||
}
|
||||
}
|
||||
failure, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "task-42"}, []byte(`{"data":{"status":"failed","error_code":"task_failed","error_message":"upstream failed"}}`))
|
||||
if err != nil || failure.Status != StatusFailed || failure.Message != "upstream failed" {
|
||||
t.Fatalf("failure = %#v, %v", failure, err)
|
||||
}
|
||||
empty, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "task-42"}, []byte(`{"data":{"status":"completed","result":{"data":{}}}}`))
|
||||
if err != nil || empty.Result != nil {
|
||||
t.Fatalf("empty output = %#v, %v", empty, err)
|
||||
}
|
||||
special, err := kacangAdapter(t, "kacang-midjourney-special").ParseCreate(context.Background(), []byte(`{"data":[{"url":"https://example.com/1.png"}]}`))
|
||||
if err != nil || special.Status != StatusSucceeded || special.Result == nil || len(special.Result.Images) != 1 {
|
||||
t.Fatalf("special = %#v, %v", special, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user