Add WaveSpeed image editing and image tools plugins, canvas image editing workflows, layer selection, and Cloud Agent image tools with approval-safe transient references.
202 lines
9.1 KiB
Go
202 lines
9.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestWavespeedImageEditConformance 验证 wavespeed-image-edit 插件在宿主运行时的完整链路:
|
|
// create(POST /openai/gpt-image-2.5-sunburst/edit) -> taskId(data.id) -> poll(GET /predictions/{id}/result) -> outputs 结果映射。
|
|
func TestWavespeedImageEditConformance(t *testing.T) {
|
|
allowLoopbackProviderTest(t)
|
|
manifest := []byte(`{
|
|
"apiVersion":"yingce.plugin/v2",
|
|
"id":"test-wavespeed-image-edit","version":"1.0.0","name":"WaveSpeed Image Edit","author":"WaveSpeed / 影策","documentation":"# WaveSpeed Image Edit",
|
|
"permissions":["generation.run","media.read"],
|
|
"configuration":{"fields":[{"name":"apiKey","type":"secret","label":"WaveSpeed API Key","required":true}]},
|
|
"contributes":{"providers":[{
|
|
"id":"test-wavespeed-image-edit","label":"WaveSpeed Image Edit","capabilities":["image"],
|
|
"scopes":["admin.system-channel","user.custom-channel","canvas","creation","agent"],
|
|
"baseUrl":"https://api.wavespeed.ai/api/v3","requiresPublicMediaUrls":true,
|
|
"auth":{"type":"bearer","field":"apiKey"},
|
|
"parameters":[
|
|
{"name":"model","type":"string","required":true,"mapping":"model","description":"WaveSpeed 图片编辑模型端点 ID。"},
|
|
{"name":"prompt","type":"string","required":true,"mapping":"prompt","description":"编辑提示词。"},
|
|
{"name":"images","type":"media[]","required":false,"mapping":"provider image/reference fields","description":"编辑源图。"},
|
|
{"name":"aspectRatio","type":"string","required":false,"mapping":"size/aspect_ratio","description":"比例。"},
|
|
{"name":"quality","type":"string","required":false,"mapping":"quality","description":"质量。"},
|
|
{"name":"providerOptions","type":"object","required":false,"mapping":"provider-specific fields","description":"厂商扩展字段。"}
|
|
],
|
|
"create":{"method":"POST","path":"/{{model}}","contentType":"application/json","body":{
|
|
"prompt":{"$ref":"request.prompt"},
|
|
"images":{"$map":{"from":{"$ref":"request.images"},"as":"media","in":{"$ref":"media.value"}}},
|
|
"aspect_ratio":{"$omitEmpty":{"$ref":"request.aspectRatio"}},
|
|
"quality":{"$omitEmpty":{"$ref":"request.quality"}},
|
|
"output_format":{"$omitEmpty":{"$coalesce":[{"$ref":"request.providerOptions.wavespeed-image-edit.output_format"},"png"]}}
|
|
}},
|
|
"poll":{"method":"GET","path":"/predictions/{{taskId}}/result"},
|
|
"response":{
|
|
"taskId":{"$coalesce":[{"$ref":"response.data.id"},{"$ref":"response.id"}]},
|
|
"status":{"$coalesce":[{"$ref":"response.data.status"},{"$ref":"response.status"},"pending"]},
|
|
"message":{"$coalesce":[{"$ref":"response.data.error"},{"$ref":"response.data.message"},{"$ref":"response.error.message"},{"$ref":"response.message"}]},
|
|
"images":{"$ref":"response.data.outputs"},
|
|
"errorPaths":["error.code"],
|
|
"resultEphemeral":true
|
|
}
|
|
}]}
|
|
}`)
|
|
center, err := newPluginRuntime(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := center.install(testPluginPackage(t, manifest), "wavespeed-image-edit.yingce-plugin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
createCalls := 0
|
|
pollCalls := 0
|
|
var server *httptest.Server
|
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/openai/gpt-image-2.5-sunburst/edit"):
|
|
createCalls++
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode create body: %v", err)
|
|
}
|
|
if body["prompt"] != "把背景换成海边日落" {
|
|
t.Errorf("create body prompt = %v", body["prompt"])
|
|
}
|
|
images, ok := body["images"].([]any)
|
|
if !ok || len(images) != 1 || images[0] != "https://cdn.example/source.png" {
|
|
t.Errorf("create body images = %#v", body["images"])
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-001"}}`))
|
|
case r.Method == http.MethodGet && r.URL.Path == "/v1/predictions/task-ws-001/result":
|
|
pollCalls++
|
|
if pollCalls == 1 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-001","status":"processing","outputs":null}}`))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"code":200,"data":{"id":"task-ws-001","status":"completed","outputs":[%q,%q]}}`, server.URL+"/result-1.png", server.URL+"/result-2.png")))
|
|
case r.Method == http.MethodGet && (r.URL.Path == "/result-1.png" || r.URL.Path == "/result-2.png"):
|
|
w.Header().Set("Content-Type", "image/png")
|
|
_, _ = w.Write([]byte("fake-png"))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
config := providerConfig{BaseURL: server.URL, APIKey: "ws-key", Model: "openai/gpt-image-2.5-sunburst/edit", APIFormat: "openai", InterfaceType: "test-wavespeed-image-edit"}
|
|
ctx := context.Background()
|
|
ctx = withProtocolRegistry(ctx, center.registrySnapshot())
|
|
|
|
result, err := runDeclarativeProtocolTask(ctx, canvasGenerationInput{
|
|
Mode: "image",
|
|
Prompt: "把背景换成海边日落",
|
|
Config: config,
|
|
ReferenceImages: []providerMedia{{URL: "https://cdn.example/source.png"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("runDeclarativeProtocolTask() error = %v", err)
|
|
}
|
|
if createCalls != 1 {
|
|
t.Fatalf("create calls = %d, want 1", createCalls)
|
|
}
|
|
if pollCalls < 2 {
|
|
t.Fatalf("poll calls = %d, want >= 2", pollCalls)
|
|
}
|
|
// 结果应包含两张输出图
|
|
images, ok := result["images"].([]interface{})
|
|
if !ok || len(images) != 2 {
|
|
raw, _ := json.Marshal(result)
|
|
t.Fatalf("result images type/count unexpected: %s", raw)
|
|
}
|
|
for i, rawImage := range images {
|
|
image, _ := rawImage.(map[string]interface{})
|
|
if strings.TrimSpace(fmt.Sprint(image["dataUrl"])) == "" || image["mimeType"] != "image/png" {
|
|
t.Fatalf("result image %d = %#v", i, image)
|
|
}
|
|
}
|
|
_ = fmt.Sprintf("%v", result["mode"])
|
|
}
|
|
|
|
// TestWavespeedImageEditPollFailure 验证 poll 返回 failed 时错误向上传播。
|
|
func TestWavespeedImageEditPollFailure(t *testing.T) {
|
|
allowLoopbackProviderTest(t)
|
|
manifest := []byte(`{
|
|
"apiVersion":"yingce.plugin/v2",
|
|
"id":"test-wavespeed-image-edit","version":"1.0.0","name":"WaveSpeed Image Edit","author":"WaveSpeed / 影策","documentation":"# WaveSpeed Image Edit",
|
|
"permissions":["generation.run","media.read"],
|
|
"configuration":{"fields":[{"name":"apiKey","type":"secret","label":"WaveSpeed API Key","required":true}]},
|
|
"contributes":{"providers":[{
|
|
"id":"test-wavespeed-image-edit","label":"WaveSpeed Image Edit","capabilities":["image"],
|
|
"scopes":["admin.system-channel","user.custom-channel","canvas","creation","agent"],
|
|
"baseUrl":"https://api.wavespeed.ai/api/v3","requiresPublicMediaUrls":true,
|
|
"auth":{"type":"bearer","field":"apiKey"},
|
|
"parameters":[
|
|
{"name":"model","type":"string","required":true,"mapping":"model","description":"WaveSpeed 图片编辑模型端点 ID。"},
|
|
{"name":"prompt","type":"string","required":true,"mapping":"prompt","description":"编辑提示词。"}
|
|
],
|
|
"create":{"method":"POST","path":"/{{model}}","contentType":"application/json","body":{
|
|
"prompt":{"$ref":"request.prompt"}
|
|
}},
|
|
"poll":{"method":"GET","path":"/predictions/{{taskId}}/result"},
|
|
"response":{
|
|
"taskId":{"$coalesce":[{"$ref":"response.data.id"},{"$ref":"response.id"}]},
|
|
"status":{"$coalesce":[{"$ref":"response.data.status"},{"$ref":"response.status"},"pending"]},
|
|
"message":{"$coalesce":[{"$ref":"response.data.error"},{"$ref":"response.data.message"},{"$ref":"response.error.message"},{"$ref":"response.message"}]},
|
|
"images":{"$ref":"response.data.outputs"},
|
|
"errorPaths":["error.code"],
|
|
"resultEphemeral":true
|
|
}
|
|
}]}
|
|
}`)
|
|
center, err := newPluginRuntime(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := center.install(testPluginPackage(t, manifest), "wavespeed-image-edit.yingce-plugin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/openai/gpt-image-2.5-sunburst/edit"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-fail"}}`))
|
|
case r.Method == http.MethodGet && r.URL.Path == "/v1/predictions/task-ws-fail/result":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-fail","status":"failed","error":"content policy violation"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
config := providerConfig{BaseURL: server.URL, APIKey: "ws-key", Model: "openai/gpt-image-2.5-sunburst/edit", APIFormat: "openai", InterfaceType: "test-wavespeed-image-edit"}
|
|
ctx := context.Background()
|
|
ctx = withProtocolRegistry(ctx, center.registrySnapshot())
|
|
|
|
_, err = runDeclarativeProtocolTask(ctx, canvasGenerationInput{
|
|
Mode: "image",
|
|
Prompt: "edit",
|
|
Config: config,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error on failed poll, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "content policy violation") {
|
|
t.Fatalf("error = %v, want content policy violation", err)
|
|
}
|
|
}
|