feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
DAO-QIN
2026-10-04 10:43:31 +08:00
committed by ddcat666
parent 002a45f0c3
commit 853c7d2ce7
107 changed files with 9261 additions and 408 deletions

View 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)
}
}
}

View 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)
}
}