Merge origin/main into chore/devenv-setup
This commit is contained in:
commit
be283daaa3
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ node_modules/
|
|||||||
|
|
||||||
apps/api/bin/
|
apps/api/bin/
|
||||||
apps/api/tmp/
|
apps/api/tmp/
|
||||||
|
apps/api/data/
|
||||||
|
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
|||||||
@ -329,6 +329,12 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
|||||||
var gotModel string
|
var gotModel string
|
||||||
var gotText string
|
var gotText string
|
||||||
var gotFirstFrameRole string
|
var gotFirstFrameRole string
|
||||||
|
var gotDuration float64
|
||||||
|
var gotRatio string
|
||||||
|
var gotResolution string
|
||||||
|
var gotSeed float64
|
||||||
|
var gotCameraFixed bool
|
||||||
|
var gotWatermark bool
|
||||||
var submittedRemoteTaskID string
|
var submittedRemoteTaskID string
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
gotAuth = r.Header.Get("Authorization")
|
gotAuth = r.Header.Get("Authorization")
|
||||||
@ -343,6 +349,17 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
|||||||
if body["prompt"] != nil || body["first_frame"] != nil {
|
if body["prompt"] != nil || body["first_frame"] != nil {
|
||||||
t.Fatalf("video convenience fields leaked upstream: %+v", body)
|
t.Fatalf("video convenience fields leaked upstream: %+v", body)
|
||||||
}
|
}
|
||||||
|
for _, key := range []string{"duration_seconds", "aspect_ratio", "audio", "cameraFixed"} {
|
||||||
|
if _, ok := body[key]; ok {
|
||||||
|
t.Fatalf("volces video task body should not include top-level %s: %+v", key, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gotDuration, _ = body["duration"].(float64)
|
||||||
|
gotRatio, _ = body["ratio"].(string)
|
||||||
|
gotResolution, _ = body["resolution"].(string)
|
||||||
|
gotSeed, _ = body["seed"].(float64)
|
||||||
|
gotCameraFixed, _ = body["camera_fixed"].(bool)
|
||||||
|
gotWatermark, _ = body["watermark"].(bool)
|
||||||
content, _ := body["content"].([]any)
|
content, _ := body["content"].([]any)
|
||||||
textItem, _ := content[0].(map[string]any)
|
textItem, _ := content[0].(map[string]any)
|
||||||
gotText, _ = textItem["text"].(string)
|
gotText, _ = textItem["text"].(string)
|
||||||
@ -375,6 +392,10 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
|||||||
"first_frame": "https://example.com/first.png",
|
"first_frame": "https://example.com/first.png",
|
||||||
"duration": 6,
|
"duration": 6,
|
||||||
"aspect_ratio": "16:9",
|
"aspect_ratio": "16:9",
|
||||||
|
"resolution": "720p",
|
||||||
|
"seed": 11,
|
||||||
|
"cameraFixed": false,
|
||||||
|
"watermark": true,
|
||||||
},
|
},
|
||||||
Candidate: store.RuntimeModelCandidate{
|
Candidate: store.RuntimeModelCandidate{
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
@ -406,10 +427,11 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
|||||||
if gotModel != "doubao-seedance-2-0-260128" || gotFirstFrameRole != "first_frame" {
|
if gotModel != "doubao-seedance-2-0-260128" || gotFirstFrameRole != "first_frame" {
|
||||||
t.Fatalf("unexpected submitted model=%s role=%s", gotModel, gotFirstFrameRole)
|
t.Fatalf("unexpected submitted model=%s role=%s", gotModel, gotFirstFrameRole)
|
||||||
}
|
}
|
||||||
for _, fragment := range []string{"A clean product reveal", "--dur 6", "--ratio 16:9", "--watermark false", "--seed -1"} {
|
if gotText != "A clean product reveal" {
|
||||||
if !strings.Contains(gotText, fragment) {
|
t.Fatalf("video params should not be appended to prompt text, got %q", gotText)
|
||||||
t.Fatalf("expected text to contain %q, got %q", fragment, gotText)
|
}
|
||||||
}
|
if gotDuration != 6 || gotRatio != "16:9" || gotResolution != "720p" || gotSeed != 11 || gotCameraFixed != false || gotWatermark != true {
|
||||||
|
t.Fatalf("unexpected submitted video params duration=%v ratio=%s resolution=%s seed=%v camera_fixed=%v watermark=%v", gotDuration, gotRatio, gotResolution, gotSeed, gotCameraFixed, gotWatermark)
|
||||||
}
|
}
|
||||||
data, _ := response.Result["data"].([]any)
|
data, _ := response.Result["data"].([]any)
|
||||||
item, _ := data[0].(map[string]any)
|
item, _ := data[0].(map[string]any)
|
||||||
@ -418,6 +440,181 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVolcesClientVideoRejectsDuplicateFirstFrameBeforeSubmit(t *testing.T) {
|
||||||
|
var submitted bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
submitted = true
|
||||||
|
t.Fatalf("duplicate first_frame request should not be submitted upstream")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Model: "豆包Seedance",
|
||||||
|
Body: map[string]any{
|
||||||
|
"model": "豆包Seedance",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "animate it"},
|
||||||
|
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||||
|
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/second.png"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
ProviderModelName: "doubao-seedance-1-5-pro-251215",
|
||||||
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil || ErrorCode(err) != "invalid_parameter" {
|
||||||
|
t.Fatalf("expected local invalid_parameter error, got %v", err)
|
||||||
|
}
|
||||||
|
if submitted {
|
||||||
|
t.Fatal("request was submitted upstream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVolcesVideoBodyAllowsOnlyTaskPayloadFields(t *testing.T) {
|
||||||
|
body := volcesVideoBody(Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "omni_video",
|
||||||
|
Model: "豆包Seedance",
|
||||||
|
Body: map[string]any{
|
||||||
|
"model": "豆包Seedance",
|
||||||
|
"duration": 8,
|
||||||
|
"duration_seconds": 8,
|
||||||
|
"aspect_ratio": "9:16",
|
||||||
|
"resolution": "720p",
|
||||||
|
"audio": true,
|
||||||
|
"callback_url": "https://example.com/callback",
|
||||||
|
"returnLastFrame": true,
|
||||||
|
"executionExpiresAfter": 3600,
|
||||||
|
"draft": false,
|
||||||
|
"cameraFixed": false,
|
||||||
|
"watermark": true,
|
||||||
|
"seed": -1,
|
||||||
|
"task_id": "local-task-id",
|
||||||
|
"runMode": "simulation",
|
||||||
|
"fps": 24,
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "Use <<<element_1>>> in a product reveal"},
|
||||||
|
map[string]any{
|
||||||
|
"type": "element",
|
||||||
|
"element": map[string]any{
|
||||||
|
"inline_element": map[string]any{
|
||||||
|
"name": "subject",
|
||||||
|
"frontal_image_url": "https://example.com/subject.png",
|
||||||
|
"refer_images": []any{map[string]any{"url": "https://example.com/side.png", "slot_key": "side"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "image_url",
|
||||||
|
"role": "unexpected_role",
|
||||||
|
"name": "drop-me",
|
||||||
|
"image_url": map[string]any{"url": "https://example.com/ref.png", "extra": "drop-me"},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "video_url",
|
||||||
|
"duration": 3,
|
||||||
|
"video_url": map[string]any{
|
||||||
|
"url": "https://example.com/ref.mp4",
|
||||||
|
"refer_type": "feature",
|
||||||
|
"keep_original_sound": "yes",
|
||||||
|
"extra": "drop-me",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "audio_url",
|
||||||
|
"audio_url": map[string]any{"url": "https://example.com/ref.mp3", "extra": "drop-me"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
ModelName: "豆包Seedance",
|
||||||
|
ProviderModelName: "doubao-seedance-2-0-260128",
|
||||||
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
allowedTopLevel := map[string]bool{
|
||||||
|
"model": true, "content": true, "callback_url": true, "return_last_frame": true, "execution_expires_after": true,
|
||||||
|
"generate_audio": true, "draft": true, "resolution": true, "ratio": true, "duration": true,
|
||||||
|
"seed": true, "camera_fixed": true, "watermark": true,
|
||||||
|
}
|
||||||
|
for key := range body {
|
||||||
|
if !allowedTopLevel[key] {
|
||||||
|
t.Fatalf("unexpected top-level volces field %q in %+v", key, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body["model"] != "doubao-seedance-2-0-260128" ||
|
||||||
|
body["generate_audio"] != true ||
|
||||||
|
body["callback_url"] != "https://example.com/callback" ||
|
||||||
|
body["return_last_frame"] != true ||
|
||||||
|
body["execution_expires_after"] != 3600 ||
|
||||||
|
body["draft"] != false ||
|
||||||
|
body["resolution"] != "720p" ||
|
||||||
|
body["ratio"] != "9:16" ||
|
||||||
|
body["duration"] != 8 ||
|
||||||
|
body["seed"] != -1 ||
|
||||||
|
body["camera_fixed"] != false ||
|
||||||
|
body["watermark"] != true {
|
||||||
|
t.Fatalf("unexpected direct video fields: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, ok := body["content"].([]map[string]any)
|
||||||
|
if !ok || len(content) != 5 {
|
||||||
|
t.Fatalf("unexpected sanitized content: %#v", body["content"])
|
||||||
|
}
|
||||||
|
text := content[0]
|
||||||
|
if text["type"] != "text" || strings.Contains(text["text"].(string), "--dur") || strings.Contains(text["text"].(string), "--ratio") {
|
||||||
|
t.Fatalf("video params should not be appended to the text item: %+v", text)
|
||||||
|
}
|
||||||
|
elementImage := content[1]
|
||||||
|
if elementImage["type"] != "image_url" || elementImage["role"] != "reference_image" {
|
||||||
|
t.Fatalf("referenced element should be converted to reference image: %+v", elementImage)
|
||||||
|
}
|
||||||
|
imageURL, _ := elementImage["image_url"].(map[string]any)
|
||||||
|
if imageURL["url"] != "https://example.com/subject.png" || len(imageURL) != 1 {
|
||||||
|
t.Fatalf("element image payload should only include url: %+v", imageURL)
|
||||||
|
}
|
||||||
|
referenceImage := content[2]
|
||||||
|
if referenceImage["role"] != "reference_image" || referenceImage["name"] != nil {
|
||||||
|
t.Fatalf("image references should be role-normalized and scrubbed: %+v", referenceImage)
|
||||||
|
}
|
||||||
|
videoItem := content[3]
|
||||||
|
videoURL, _ := videoItem["video_url"].(map[string]any)
|
||||||
|
if videoItem["role"] != "reference_video" || videoURL["url"] != "https://example.com/ref.mp4" || videoURL["refer_type"] != "feature" || videoURL["extra"] != nil {
|
||||||
|
t.Fatalf("video references should keep only allowed nested fields: %+v", videoItem)
|
||||||
|
}
|
||||||
|
audioItem := content[4]
|
||||||
|
audioURL, _ := audioItem["audio_url"].(map[string]any)
|
||||||
|
if audioItem["role"] != "reference_audio" || audioURL["url"] != "https://example.com/ref.mp3" || len(audioURL) != 1 {
|
||||||
|
t.Fatalf("audio references should keep only url: %+v", audioItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVolcesVideoBodyPrefersFramesOverDuration(t *testing.T) {
|
||||||
|
body := volcesVideoBody(Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "video_generate",
|
||||||
|
Body: map[string]any{
|
||||||
|
"prompt": "A quick camera move",
|
||||||
|
"duration": 8,
|
||||||
|
"frames": 57,
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
ProviderModelName: "doubao-seedance-1-0-pro-250528",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if body["frames"] != 57 {
|
||||||
|
t.Fatalf("frames should be passed through as the official duration control: %+v", body)
|
||||||
|
}
|
||||||
|
if _, ok := body["duration"]; ok {
|
||||||
|
t.Fatalf("duration should not be sent when frames is present: %+v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVolcesClientVideoResumePollsExistingTaskID(t *testing.T) {
|
func TestVolcesClientVideoResumePollsExistingTaskID(t *testing.T) {
|
||||||
var submitCalled bool
|
var submitCalled bool
|
||||||
var pollPath string
|
var pollPath string
|
||||||
|
|||||||
@ -339,5 +339,12 @@ func firstNonEmptyPrompt(body map[string]any, fallback string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, item := range contentItems(body["content"]) {
|
||||||
|
if stringValue(item, "type") == "text" {
|
||||||
|
if value := strings.TrimSpace(stringValue(item, "text")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var volcesElementReferencePattern = regexp.MustCompile(`(?i)<<<[[:space:]]*element[_-]?([0-9]+)[[:space:]]*>>>|@element([0-9]+)`)
|
||||||
|
|
||||||
type VolcesClient struct {
|
type VolcesClient struct {
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
@ -72,6 +76,9 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
|
|||||||
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||||
if upstreamTaskID == "" {
|
if upstreamTaskID == "" {
|
||||||
body := volcesVideoBody(request)
|
body := volcesVideoBody(request)
|
||||||
|
if err := validateVolcesVideoTaskBody(body); err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
|
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
|
||||||
submitRequestID = requestID
|
submitRequestID = requestID
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -215,11 +222,9 @@ func volcesVideoBody(request Request) map[string]any {
|
|||||||
content = buildVolcesContentFromBody(body)
|
content = buildVolcesContentFromBody(body)
|
||||||
}
|
}
|
||||||
appendMultiShotTimeline(&content)
|
appendMultiShotTimeline(&content)
|
||||||
|
convertVolcesElementsToImageReferences(&content)
|
||||||
normalizeVolcesContentRoles(content)
|
normalizeVolcesContentRoles(content)
|
||||||
appendVolcesVideoParams(&content, body)
|
return volcesVideoTaskBody(body, content)
|
||||||
body["content"] = content
|
|
||||||
stripVolcesVideoConvenienceFields(body)
|
|
||||||
return body
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanProviderBody(body map[string]any) map[string]any {
|
func cleanProviderBody(body map[string]any) map[string]any {
|
||||||
@ -286,56 +291,267 @@ func buildVolcesContentFromBody(body map[string]any) []map[string]any {
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripVolcesVideoConvenienceFields(body map[string]any) {
|
func volcesVideoTaskBody(body map[string]any, content []map[string]any) map[string]any {
|
||||||
for _, key := range []string{
|
out := map[string]any{
|
||||||
"prompt",
|
"model": body["model"],
|
||||||
"input",
|
"content": sanitizeVolcesVideoContent(content),
|
||||||
"image",
|
}
|
||||||
"images",
|
addVolcesVideoTaskParams(out, body)
|
||||||
"image_url",
|
return out
|
||||||
"imageUrl",
|
}
|
||||||
"image_urls",
|
|
||||||
"imageUrls",
|
func validateVolcesVideoTaskBody(body map[string]any) error {
|
||||||
"reference_image",
|
firstFrameCount := 0
|
||||||
"referenceImage",
|
lastFrameCount := 0
|
||||||
"first_frame",
|
for _, item := range contentItems(body["content"]) {
|
||||||
"firstFrame",
|
if stringFromAny(item["type"]) != "image_url" {
|
||||||
"last_frame",
|
continue
|
||||||
"lastFrame",
|
}
|
||||||
"video",
|
switch stringFromAny(item["role"]) {
|
||||||
"video_url",
|
case "first_frame":
|
||||||
"videoUrl",
|
firstFrameCount++
|
||||||
"reference_video",
|
case "last_frame":
|
||||||
"referenceVideo",
|
lastFrameCount++
|
||||||
"audio_url",
|
}
|
||||||
"audioUrl",
|
}
|
||||||
"reference_audio",
|
if firstFrameCount > 1 {
|
||||||
"referenceAudio",
|
return &ClientError{
|
||||||
} {
|
Code: "invalid_parameter",
|
||||||
delete(body, key)
|
Message: fmt.Sprintf("content contains %d first_frame image items; expected at most one first frame image content", firstFrameCount),
|
||||||
|
StatusCode: 400,
|
||||||
|
Retryable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastFrameCount > 1 {
|
||||||
|
return &ClientError{
|
||||||
|
Code: "invalid_parameter",
|
||||||
|
Message: fmt.Sprintf("content contains %d last_frame image items; expected at most one last frame image content", lastFrameCount),
|
||||||
|
StatusCode: 400,
|
||||||
|
Retryable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addVolcesVideoTaskParams(out map[string]any, body map[string]any) {
|
||||||
|
copyVolcesStringParam(out, "callback_url", body, "callback_url", "callbackUrl")
|
||||||
|
copyVolcesBoolParam(out, "return_last_frame", body, "return_last_frame", "returnLastFrame")
|
||||||
|
copyVolcesIntParam(out, "execution_expires_after", body, "execution_expires_after", "executionExpiresAfter")
|
||||||
|
copyVolcesBoolParam(out, "generate_audio", body, "generate_audio", "generateAudio", "audio")
|
||||||
|
copyVolcesBoolParam(out, "draft", body, "draft")
|
||||||
|
copyVolcesStringParam(out, "resolution", body, "resolution", "size")
|
||||||
|
copyVolcesStringParam(out, "ratio", body, "ratio", "aspect_ratio", "aspectRatio")
|
||||||
|
if copyVolcesIntParam(out, "frames", body, "frames") {
|
||||||
|
delete(out, "duration")
|
||||||
|
} else {
|
||||||
|
copyVolcesIntParam(out, "duration", body, "duration", "duration_seconds", "durationSeconds", "dur")
|
||||||
|
}
|
||||||
|
copyVolcesIntParam(out, "seed", body, "seed")
|
||||||
|
copyVolcesBoolParam(out, "camera_fixed", body, "camera_fixed", "cameraFixed", "camerafixed", "cf")
|
||||||
|
copyVolcesBoolParam(out, "watermark", body, "watermark")
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyVolcesStringParam(out map[string]any, target string, body map[string]any, keys ...string) bool {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(stringFromAny(body[key])); value != "" {
|
||||||
|
out[target] = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyVolcesIntParam(out map[string]any, target string, body map[string]any, keys ...string) bool {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := volcesIntFromAny(body[key]); ok {
|
||||||
|
out[target] = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyVolcesBoolParam(out map[string]any, target string, body map[string]any, keys ...string) bool {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := volcesBoolFromAny(body[key]); ok {
|
||||||
|
out[target] = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func volcesIntFromAny(value any) (int, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return 0, false
|
||||||
|
case int:
|
||||||
|
return typed, true
|
||||||
|
case int64:
|
||||||
|
return int(typed), true
|
||||||
|
case float64:
|
||||||
|
return int(math.Round(typed)), true
|
||||||
|
case string:
|
||||||
|
text := strings.TrimSpace(typed)
|
||||||
|
if text == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if parsed, err := strconv.ParseFloat(text, 64); err == nil {
|
||||||
|
return int(math.Round(parsed)), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func contentItems(value any) []map[string]any {
|
func volcesBoolFromAny(value any) (bool, bool) {
|
||||||
rawItems, ok := value.([]any)
|
switch typed := value.(type) {
|
||||||
if !ok {
|
case nil:
|
||||||
return nil
|
return false, false
|
||||||
|
case bool:
|
||||||
|
return typed, true
|
||||||
|
case int:
|
||||||
|
if typed == 1 {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if typed == 0 {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if typed == 1 {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if typed == 0 {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if typed == 1 {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if typed == 0 {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(typed))
|
||||||
|
if normalized == "true" || normalized == "1" {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if normalized == "false" || normalized == "0" {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out := make([]map[string]any, 0, len(rawItems))
|
return false, false
|
||||||
for _, raw := range rawItems {
|
}
|
||||||
item, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
func sanitizeVolcesVideoContent(content []map[string]any) []map[string]any {
|
||||||
continue
|
out := make([]map[string]any, 0, len(content))
|
||||||
|
for _, item := range content {
|
||||||
|
switch stringFromAny(item["type"]) {
|
||||||
|
case "text":
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": strings.TrimSpace(stringFromAny(item["text"])),
|
||||||
|
})
|
||||||
|
case "image_url":
|
||||||
|
url := volcesNestedURL(item, "image_url")
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"type": "image_url",
|
||||||
|
"role": volcesImageRole(item),
|
||||||
|
"image_url": map[string]any{"url": url},
|
||||||
|
})
|
||||||
|
case "video_url":
|
||||||
|
url := volcesNestedURL(item, "video_url")
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
videoURL := map[string]any{"url": url}
|
||||||
|
if value := strings.TrimSpace(stringFromAny(mapFromAny(item["video_url"])["refer_type"])); value != "" {
|
||||||
|
videoURL["refer_type"] = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(stringFromAny(mapFromAny(item["video_url"])["keep_original_sound"])); value != "" {
|
||||||
|
videoURL["keep_original_sound"] = value
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"type": "video_url",
|
||||||
|
"role": "reference_video",
|
||||||
|
"video_url": videoURL,
|
||||||
|
})
|
||||||
|
case "audio_url":
|
||||||
|
url := volcesNestedURL(item, "audio_url")
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"type": "audio_url",
|
||||||
|
"role": "reference_audio",
|
||||||
|
"audio_url": map[string]any{"url": url},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
copied := map[string]any{}
|
}
|
||||||
for key, value := range item {
|
if len(out) == 0 {
|
||||||
copied[key] = value
|
return []map[string]any{{"type": "text", "text": ""}}
|
||||||
}
|
|
||||||
out = append(out, copied)
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func volcesImageRole(item map[string]any) string {
|
||||||
|
switch strings.TrimSpace(stringFromAny(item["role"])) {
|
||||||
|
case "first_frame":
|
||||||
|
return "first_frame"
|
||||||
|
case "last_frame":
|
||||||
|
return "last_frame"
|
||||||
|
default:
|
||||||
|
return "reference_image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func volcesNestedURL(item map[string]any, key string) string {
|
||||||
|
nested := mapFromAny(item[key])
|
||||||
|
return strings.TrimSpace(stringFromAny(nested["url"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapFromAny(value any) map[string]any {
|
||||||
|
if object, ok := value.(map[string]any); ok {
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentItems(value any) []map[string]any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, raw := range typed {
|
||||||
|
item, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := map[string]any{}
|
||||||
|
for key, value := range item {
|
||||||
|
copied[key] = value
|
||||||
|
}
|
||||||
|
out = append(out, copied)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []map[string]any:
|
||||||
|
out := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
copied := map[string]any{}
|
||||||
|
for key, value := range item {
|
||||||
|
copied[key] = value
|
||||||
|
}
|
||||||
|
out = append(out, copied)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeVolcesContentRoles(content []map[string]any) {
|
func normalizeVolcesContentRoles(content []map[string]any) {
|
||||||
for _, item := range content {
|
for _, item := range content {
|
||||||
itemType := strings.TrimSpace(stringFromAny(item["type"]))
|
itemType := strings.TrimSpace(stringFromAny(item["type"]))
|
||||||
@ -353,32 +569,115 @@ func normalizeVolcesContentRoles(content []map[string]any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendVolcesVideoParams(content *[]map[string]any, body map[string]any) {
|
func convertVolcesElementsToImageReferences(content *[]map[string]any) {
|
||||||
textItem := ensureTextContent(content)
|
referenced := referencedVolcesElementIndexes(*content)
|
||||||
current := strings.TrimSpace(stringFromAny(textItem["text"]))
|
out := make([]map[string]any, 0, len(*content))
|
||||||
values := []struct {
|
elementIndex := 0
|
||||||
key string
|
for _, item := range *content {
|
||||||
value any
|
if stringFromAny(item["type"]) != "element" {
|
||||||
}{
|
out = append(out, item)
|
||||||
{"dur", firstPresent(body["duration"], body["dur"])},
|
|
||||||
{"ratio", firstPresent(body["aspect_ratio"], body["aspectRatio"], body["ratio"])},
|
|
||||||
{"fps", firstPresent(body["framespersecond"], body["framesPerSecond"], body["fps"])},
|
|
||||||
{"watermark", firstPresent(body["watermark"], false)},
|
|
||||||
{"seed", firstPresent(body["seed"], -1)},
|
|
||||||
{"cf", firstPresent(body["camerafixed"], body["cameraFixed"])},
|
|
||||||
{"rs", firstPresent(body["resolution"], body["size"])},
|
|
||||||
}
|
|
||||||
for _, item := range values {
|
|
||||||
valueText := volcesParamString(item.value)
|
|
||||||
if valueText == "" || strings.Contains(current, "--"+item.key) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if current != "" {
|
elementIndex++
|
||||||
current += " "
|
if !referenced[elementIndex] {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
current += "--" + item.key + " " + valueText
|
url := volcesElementFrontalImageURL(item)
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := stringFromAny(item["role"])
|
||||||
|
if role != "first_frame" && role != "last_frame" {
|
||||||
|
role = "reference_image"
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"type": "image_url",
|
||||||
|
"role": role,
|
||||||
|
"image_url": map[string]any{"url": url},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
*content = out
|
||||||
|
}
|
||||||
|
|
||||||
|
func referencedVolcesElementIndexes(content []map[string]any) map[int]bool {
|
||||||
|
out := map[int]bool{}
|
||||||
|
for _, item := range content {
|
||||||
|
if stringFromAny(item["type"]) != "text" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := stringFromAny(item["text"])
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, match := range volcesElementReferencePattern.FindAllStringSubmatch(text, -1) {
|
||||||
|
raw := ""
|
||||||
|
if len(match) > 1 && match[1] != "" {
|
||||||
|
raw = match[1]
|
||||||
|
} else if len(match) > 2 {
|
||||||
|
raw = match[2]
|
||||||
|
}
|
||||||
|
index, err := strconv.Atoi(raw)
|
||||||
|
if err == nil && index > 0 {
|
||||||
|
out[index] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func volcesElementFrontalImageURL(item map[string]any) string {
|
||||||
|
element := mapFromAny(item["element"])
|
||||||
|
if element == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
inline := mapFromAny(element["inline_element"])
|
||||||
|
for _, value := range []any{
|
||||||
|
inline["frontal_image_url"],
|
||||||
|
element["frontal_image_url"],
|
||||||
|
element["front_image_url"],
|
||||||
|
element["image_url"],
|
||||||
|
} {
|
||||||
|
if url := strings.TrimSpace(stringFromAny(value)); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return volcesReferImageURL(firstPresent(inline["refer_images"], element["refer_images"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func volcesReferImageURL(value any) string {
|
||||||
|
images := mapListFromAny(value)
|
||||||
|
firstURL := ""
|
||||||
|
for _, image := range images {
|
||||||
|
url := strings.TrimSpace(stringFromAny(image["url"]))
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if firstURL == "" {
|
||||||
|
firstURL = url
|
||||||
|
}
|
||||||
|
slot := strings.ToLower(strings.TrimSpace(stringFromAny(image["slot_key"])))
|
||||||
|
if slot == "frontal" || slot == "front" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapListFromAny(value any) []map[string]any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if object := mapFromAny(item); object != nil {
|
||||||
|
out = append(out, object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []map[string]any:
|
||||||
|
return typed
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
textItem["text"] = current
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendMultiShotTimeline(content *[]map[string]any) {
|
func appendMultiShotTimeline(content *[]map[string]any) {
|
||||||
@ -625,31 +924,6 @@ func firstNonEmptyStringListFromAny(values ...any) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func volcesParamString(value any) string {
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
case string:
|
|
||||||
return strings.TrimSpace(typed)
|
|
||||||
case bool:
|
|
||||||
if typed {
|
|
||||||
return "true"
|
|
||||||
}
|
|
||||||
return "false"
|
|
||||||
case int:
|
|
||||||
return fmt.Sprintf("%d", typed)
|
|
||||||
case int64:
|
|
||||||
return fmt.Sprintf("%d", typed)
|
|
||||||
case float64:
|
|
||||||
if math.Mod(typed, 1) == 0 {
|
|
||||||
return fmt.Sprintf("%d", int64(typed))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%g", typed)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%v", typed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func numericValue(value any, fallback float64) float64 {
|
func numericValue(value any, fallback float64) float64 {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case int:
|
case int:
|
||||||
|
|||||||
@ -7,6 +7,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultLocalGeneratedStorageDir = "data/static/generated"
|
||||||
|
DefaultLocalUploadedStorageDir = "data/static/uploaded"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppEnv string
|
AppEnv string
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
@ -15,6 +20,9 @@ type Config struct {
|
|||||||
JWTSecret string
|
JWTSecret string
|
||||||
ServerMainBaseURL string
|
ServerMainBaseURL string
|
||||||
ServerMainInternalToken string
|
ServerMainInternalToken string
|
||||||
|
PublicBaseURL string
|
||||||
|
LocalGeneratedStorageDir string
|
||||||
|
LocalUploadedStorageDir string
|
||||||
TaskProgressCallbackEnabled bool
|
TaskProgressCallbackEnabled bool
|
||||||
TaskProgressCallbackURL string
|
TaskProgressCallbackURL string
|
||||||
TaskProgressCallbackTimeoutMS string
|
TaskProgressCallbackTimeoutMS string
|
||||||
@ -38,6 +46,9 @@ func Load() Config {
|
|||||||
"/",
|
"/",
|
||||||
),
|
),
|
||||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||||
|
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
|
||||||
|
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
|
||||||
|
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
|
||||||
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
||||||
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
|
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
|
||||||
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
|
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
|
||||||
|
|||||||
58
apps/api/internal/httpapi/file_upload_handlers.go
Normal file
58
apps/api/internal/httpapi/file_upload_handlers.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxGatewayUploadBytes = 256 << 20
|
||||||
|
|
||||||
|
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid multipart upload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
payload, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "read upload file failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
|
||||||
|
if contentType == "" && len(payload) > 0 {
|
||||||
|
contentType = http.DetectContentType(payload)
|
||||||
|
}
|
||||||
|
upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{
|
||||||
|
Bytes: payload,
|
||||||
|
ContentType: contentType,
|
||||||
|
FileName: header.Filename,
|
||||||
|
Source: firstNonEmptyFormValue(r, "source", "ai-gateway-openapi"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("upload file failed", "error", err)
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if clients.ErrorCode(err) == "upload_no_channel" {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeError(w, status, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, upload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyFormValue(r *http.Request, key string, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(r.FormValue(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@ -597,7 +597,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
status := statusFromRunError(runErr)
|
status := statusFromRunError(runErr)
|
||||||
errorPayload := map[string]any{
|
errorPayload := map[string]any{
|
||||||
"code": runErrorCode(runErr),
|
"code": runErrorCode(runErr),
|
||||||
"message": runErr.Error(),
|
"message": runErrorMessage(runErr),
|
||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
if result.Task.ID != "" {
|
if result.Task.ID != "" {
|
||||||
@ -606,6 +606,9 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
if result.Task.RequestID != "" {
|
if result.Task.RequestID != "" {
|
||||||
errorPayload["requestId"] = result.Task.RequestID
|
errorPayload["requestId"] = result.Task.RequestID
|
||||||
}
|
}
|
||||||
|
for key, value := range runErrorDetails(runErr) {
|
||||||
|
errorPayload[key] = value
|
||||||
|
}
|
||||||
sendSSE(w, "error", map[string]any{"error": errorPayload})
|
sendSSE(w, "error", map[string]any{"error": errorPayload})
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@ -626,7 +629,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
if !requestStillConnected(r) {
|
if !requestStillConnected(r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, statusFromRunError(runErr), runErr.Error(), runErrorCode(runErr))
|
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !requestStillConnected(r) {
|
if !requestStillConnected(r) {
|
||||||
@ -742,6 +745,138 @@ func runErrorCode(err error) string {
|
|||||||
return clients.ErrorCode(err)
|
return clients.ErrorCode(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runErrorMessage(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if summary := rateLimitErrorSummary(err); summary != "" {
|
||||||
|
return err.Error() + ";" + summary
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runErrorDetails(err error) map[string]any {
|
||||||
|
if detail := rateLimitErrorDetail(err); len(detail) > 0 {
|
||||||
|
return map[string]any{"rateLimit": detail}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rateLimitErrorSummary(err error) string {
|
||||||
|
var limitErr *store.RateLimitExceededError
|
||||||
|
if !errors.As(err, &limitErr) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
scopeLabel := "限流对象"
|
||||||
|
switch limitErr.ScopeType {
|
||||||
|
case "user_group":
|
||||||
|
scopeLabel = "用户组"
|
||||||
|
case "platform_model":
|
||||||
|
scopeLabel = "平台模型"
|
||||||
|
}
|
||||||
|
scopeName := strings.TrimSpace(limitErr.ScopeName)
|
||||||
|
if scopeName == "" {
|
||||||
|
scopeName = strings.TrimSpace(limitErr.ScopeKey)
|
||||||
|
}
|
||||||
|
if groupKey := stringValue(limitErr.ScopeMetadata["groupKey"]); limitErr.ScopeType == "user_group" && groupKey != "" && groupKey != scopeName {
|
||||||
|
scopeName = fmt.Sprintf("%s(%s)", scopeName, groupKey)
|
||||||
|
}
|
||||||
|
projected := limitErr.Projected
|
||||||
|
if projected <= 0 {
|
||||||
|
projected = limitErr.Current + limitErr.Amount
|
||||||
|
}
|
||||||
|
parts := []string{
|
||||||
|
fmt.Sprintf("限流摘要:%s %s 的 %s 超限", scopeLabel, scopeName, limitErr.Metric),
|
||||||
|
fmt.Sprintf("当前 %s,本次 %s,预计 %s,限制 %s", formatRateLimitValue(limitErr.Current), formatRateLimitValue(limitErr.Amount), formatRateLimitValue(projected), formatRateLimitValue(limitErr.Limit)),
|
||||||
|
}
|
||||||
|
if limitErr.WindowSeconds > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("窗口 %d 秒", limitErr.WindowSeconds))
|
||||||
|
}
|
||||||
|
if limitErr.RetryAfter > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("约%s后可重试", formatRateLimitDuration(limitErr.RetryAfter)))
|
||||||
|
} else if !limitErr.Retryable {
|
||||||
|
parts = append(parts, "该请求超过单次限额,不能排队重试")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func rateLimitErrorDetail(err error) map[string]any {
|
||||||
|
var limitErr *store.RateLimitExceededError
|
||||||
|
if !errors.As(err, &limitErr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
detail := map[string]any{
|
||||||
|
"scopeType": limitErr.ScopeType,
|
||||||
|
"scopeKey": limitErr.ScopeKey,
|
||||||
|
"scopeName": limitErr.ScopeName,
|
||||||
|
"metric": limitErr.Metric,
|
||||||
|
"limit": limitErr.Limit,
|
||||||
|
"amount": limitErr.Amount,
|
||||||
|
"current": limitErr.Current,
|
||||||
|
"used": limitErr.Used,
|
||||||
|
"reserved": limitErr.Reserved,
|
||||||
|
"projected": limitErr.Projected,
|
||||||
|
"windowSeconds": limitErr.WindowSeconds,
|
||||||
|
"retryable": limitErr.Retryable,
|
||||||
|
"exceeded": map[string]any{
|
||||||
|
"metric": limitErr.Metric,
|
||||||
|
"current": limitErr.Current,
|
||||||
|
"amount": limitErr.Amount,
|
||||||
|
"projected": limitErr.Projected,
|
||||||
|
"limit": limitErr.Limit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if limitErr.RetryAfter > 0 {
|
||||||
|
detail["retryAfterMs"] = limitErr.RetryAfter.Milliseconds()
|
||||||
|
}
|
||||||
|
if !limitErr.ResetAt.IsZero() {
|
||||||
|
detail["resetAt"] = limitErr.ResetAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
if len(limitErr.Policy) > 0 {
|
||||||
|
detail["rateLimitPolicy"] = limitErr.Policy
|
||||||
|
if matchedRule := matchedRateLimitRule(limitErr.Policy, limitErr.Metric); len(matchedRule) > 0 {
|
||||||
|
detail["matchedRule"] = matchedRule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(limitErr.ScopeMetadata) > 0 {
|
||||||
|
detail["scopeMetadata"] = limitErr.ScopeMetadata
|
||||||
|
}
|
||||||
|
if limitErr.ScopeType == "user_group" {
|
||||||
|
userGroup := map[string]any{
|
||||||
|
"id": limitErr.ScopeKey,
|
||||||
|
"name": limitErr.ScopeName,
|
||||||
|
}
|
||||||
|
if groupKey := stringValue(limitErr.ScopeMetadata["groupKey"]); groupKey != "" {
|
||||||
|
userGroup["groupKey"] = groupKey
|
||||||
|
}
|
||||||
|
detail["userGroup"] = userGroup
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRateLimitValue(value float64) string {
|
||||||
|
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRateLimitDuration(duration time.Duration) string {
|
||||||
|
if duration < time.Second {
|
||||||
|
return strconv.FormatInt(duration.Milliseconds(), 10) + "毫秒"
|
||||||
|
}
|
||||||
|
seconds := duration.Seconds()
|
||||||
|
return strconv.FormatFloat(seconds, 'f', -1, 64) + "秒"
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
|
||||||
|
rules, _ := policy["rules"].([]any)
|
||||||
|
for _, rawRule := range rules {
|
||||||
|
rule, _ := rawRule.(map[string]any)
|
||||||
|
if stringValue(rule["metric"]) == metric {
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := auth.UserFromContext(r.Context())
|
user, ok := auth.UserFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
72
apps/api/internal/httpapi/rate_limit_error_detail_test.go
Normal file
72
apps/api/internal/httpapi/rate_limit_error_detail_test.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRateLimitErrorDetailIncludesUserGroupAndExceededMetric(t *testing.T) {
|
||||||
|
resetAt := time.Date(2026, 5, 15, 10, 30, 0, 0, time.UTC)
|
||||||
|
detail := rateLimitErrorDetail(&store.RateLimitExceededError{
|
||||||
|
ScopeType: "user_group",
|
||||||
|
ScopeKey: "group-1",
|
||||||
|
ScopeName: "VIP 用户组",
|
||||||
|
ScopeMetadata: map[string]any{"groupKey": "vip"},
|
||||||
|
Metric: "rpm",
|
||||||
|
Limit: 2,
|
||||||
|
Amount: 1,
|
||||||
|
Current: 2,
|
||||||
|
Used: 1,
|
||||||
|
Reserved: 1,
|
||||||
|
Projected: 3,
|
||||||
|
WindowSeconds: 60,
|
||||||
|
ResetAt: resetAt,
|
||||||
|
RetryAfter: 5 * time.Second,
|
||||||
|
Retryable: true,
|
||||||
|
Policy: map[string]any{
|
||||||
|
"rules": []any{
|
||||||
|
map[string]any{"metric": "rpm", "limit": float64(2), "windowSeconds": float64(60)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if detail["metric"] != "rpm" || detail["projected"] != float64(3) || detail["limit"] != float64(2) {
|
||||||
|
t.Fatalf("unexpected exceeded detail: %+v", detail)
|
||||||
|
}
|
||||||
|
userGroup, _ := detail["userGroup"].(map[string]any)
|
||||||
|
if userGroup["id"] != "group-1" || userGroup["groupKey"] != "vip" || userGroup["name"] != "VIP 用户组" {
|
||||||
|
t.Fatalf("missing user group detail: %+v", detail)
|
||||||
|
}
|
||||||
|
matchedRule, _ := detail["matchedRule"].(map[string]any)
|
||||||
|
if matchedRule["metric"] != "rpm" {
|
||||||
|
t.Fatalf("missing matched rule: %+v", detail)
|
||||||
|
}
|
||||||
|
if detail["retryAfterMs"] != int64(5000) || detail["resetAt"] != resetAt.Format(time.RFC3339Nano) {
|
||||||
|
t.Fatalf("missing retry/reset detail: %+v", detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunErrorMessageIncludesRateLimitSummary(t *testing.T) {
|
||||||
|
message := runErrorMessage(&store.RateLimitExceededError{
|
||||||
|
ScopeType: "user_group",
|
||||||
|
ScopeKey: "group-1",
|
||||||
|
ScopeName: "VIP 用户组",
|
||||||
|
ScopeMetadata: map[string]any{"groupKey": "vip"},
|
||||||
|
Metric: "rpm",
|
||||||
|
Limit: 2,
|
||||||
|
Amount: 1,
|
||||||
|
Current: 2,
|
||||||
|
Projected: 3,
|
||||||
|
WindowSeconds: 60,
|
||||||
|
RetryAfter: 5 * time.Second,
|
||||||
|
Retryable: true,
|
||||||
|
Message: "rate limit exceeded: rpm window has no remaining capacity",
|
||||||
|
})
|
||||||
|
for _, expected := range []string{"限流摘要", "用户组 VIP 用户组(vip)", "rpm 超限", "当前 2", "本次 1", "预计 3", "限制 2", "窗口 60 秒", "约5秒后可重试"} {
|
||||||
|
if !strings.Contains(message, expected) {
|
||||||
|
t.Fatalf("message %q should contain %q", message, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,10 @@ func writeJSON(w http.ResponseWriter, status int, value any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
|
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
|
||||||
|
writeErrorWithDetails(w, status, message, nil, codes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErrorWithDetails(w http.ResponseWriter, status int, message string, details map[string]any, codes ...string) {
|
||||||
errorPayload := map[string]any{
|
errorPayload := map[string]any{
|
||||||
"message": message,
|
"message": message,
|
||||||
"status": status,
|
"status": status,
|
||||||
@ -23,6 +27,9 @@ func writeError(w http.ResponseWriter, status int, message string, codes ...stri
|
|||||||
errorPayload["code"] = code
|
errorPayload["code"] = code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for key, value := range details {
|
||||||
|
errorPayload[key] = value
|
||||||
|
}
|
||||||
writeJSON(w, status, map[string]any{"error": errorPayload})
|
writeJSON(w, status, map[string]any{"error": errorPayload})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
mux.HandleFunc("GET /healthz", server.health)
|
mux.HandleFunc("GET /healthz", server.health)
|
||||||
mux.HandleFunc("GET /readyz", server.ready)
|
mux.HandleFunc("GET /readyz", server.ready)
|
||||||
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
|
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
|
||||||
|
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
|
||||||
|
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
|
||||||
|
|
||||||
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
||||||
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
||||||
@ -102,6 +104,12 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
mux.Handle("GET /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getRunnerPolicy)))
|
mux.Handle("GET /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getRunnerPolicy)))
|
||||||
mux.Handle("PATCH /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRunnerPolicy)))
|
mux.Handle("PATCH /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRunnerPolicy)))
|
||||||
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
|
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
|
||||||
|
mux.Handle("GET /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getFileStorageSettings)))
|
||||||
|
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
|
||||||
|
mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels)))
|
||||||
|
mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel)))
|
||||||
|
mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel)))
|
||||||
|
mux.Handle("DELETE /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteFileStorageChannel)))
|
||||||
mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
|
mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
|
||||||
mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
|
mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
|
||||||
mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform)))
|
mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform)))
|
||||||
@ -123,6 +131,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
|
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||||
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
|
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||||
mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
||||||
|
mux.Handle("POST /api/v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||||
mux.Handle("GET /api/v1/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
mux.Handle("GET /api/v1/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||||
mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||||
mux.Handle("GET /api/v1/tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
mux.Handle("GET /api/v1/tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
||||||
@ -135,6 +144,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
mux.Handle("POST /v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true)))
|
mux.Handle("POST /v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||||
mux.Handle("POST /images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
|
mux.Handle("POST /images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||||
mux.Handle("POST /v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
|
mux.Handle("POST /v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||||
|
mux.Handle("POST /v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||||
|
|
||||||
return server.recover(server.cors(mux))
|
return server.recover(server.cors(mux))
|
||||||
}
|
}
|
||||||
|
|||||||
37
apps/api/internal/httpapi/static_assets.go
Normal file
37
apps/api/internal/httpapi/static_assets.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) serveGeneratedStaticAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.serveLocalStaticAsset(w, r, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveUploadedStaticAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.serveLocalStaticAsset(w, r, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveLocalStaticAsset(w http.ResponseWriter, r *http.Request, storageDir string, fallbackStorageDir string) {
|
||||||
|
fileName := filepath.Base(strings.TrimSpace(r.PathValue("asset")))
|
||||||
|
if fileName == "" || fileName == "." || fileName == ".." || fileName == string(filepath.Separator) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storageDir = strings.TrimSpace(storageDir)
|
||||||
|
if storageDir == "" {
|
||||||
|
storageDir = fallbackStorageDir
|
||||||
|
}
|
||||||
|
filePath := filepath.Join(storageDir, fileName)
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
}
|
||||||
65
apps/api/internal/httpapi/static_assets_test.go
Normal file
65
apps/api/internal/httpapi/static_assets_test.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeGeneratedStaticAsset(t *testing.T) {
|
||||||
|
storageDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(storageDir, "result.png"), []byte("png"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write generated asset fixture: %v", err)
|
||||||
|
}
|
||||||
|
server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/static/generated/result.png", nil)
|
||||||
|
request.SetPathValue("asset", "result.png")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.serveGeneratedStaticAsset(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected generated asset to be served, got status %d", response.Code)
|
||||||
|
}
|
||||||
|
if response.Body.String() != "png" {
|
||||||
|
t.Fatalf("unexpected generated asset payload: %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeUploadedStaticAsset(t *testing.T) {
|
||||||
|
storageDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(storageDir, "upload.pdf"), []byte("pdf"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write uploaded asset fixture: %v", err)
|
||||||
|
}
|
||||||
|
server := &Server{cfg: config.Config{LocalUploadedStorageDir: storageDir}}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/static/uploaded/upload.pdf", nil)
|
||||||
|
request.SetPathValue("asset", "upload.pdf")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.serveUploadedStaticAsset(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected uploaded asset to be served, got status %d", response.Code)
|
||||||
|
}
|
||||||
|
if response.Body.String() != "pdf" {
|
||||||
|
t.Fatalf("unexpected uploaded asset payload: %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeLocalStaticAssetRejectsTraversal(t *testing.T) {
|
||||||
|
storageDir := t.TempDir()
|
||||||
|
server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/static/generated/..", nil)
|
||||||
|
request.SetPathValue("asset", "..")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.serveGeneratedStaticAsset(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected traversal-like generated asset name to 404, got status %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
150
apps/api/internal/httpapi/system_settings_handlers.go
Normal file
150
apps/api/internal/httpapi/system_settings_handlers.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) listFileStorageChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := s.store.ListFileStorageChannels(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("list file storage channels failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "list file storage channels failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getFileStorageSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := s.store.GetFileStorageSettings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
if store.IsUndefinedDatabaseObject(err) {
|
||||||
|
writeJSON(w, http.StatusOK, store.DefaultFileStorageSettings())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("get file storage settings failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "get file storage settings failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateFileStorageSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input store.FileStorageSettingsInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := s.store.UpdateFileStorageSettings(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("update file storage settings failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "update file storage settings failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input store.FileStorageChannelInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if message := validateFileStorageChannelInput(input, nil); message != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := s.store.CreateFileStorageChannel(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
if store.IsUniqueViolation(err) {
|
||||||
|
writeError(w, http.StatusConflict, "file storage channel key already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("create file storage channel failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "create file storage channel failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input store.FileStorageChannelInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, err := s.store.GetFileStorageChannel(r.Context(), r.PathValue("channelID"))
|
||||||
|
if err != nil {
|
||||||
|
if store.IsNotFound(err) {
|
||||||
|
writeError(w, http.StatusNotFound, "file storage channel not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("get file storage channel failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "get file storage channel failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if message := validateFileStorageChannelInput(input, &existing); message != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := s.store.UpdateFileStorageChannel(r.Context(), r.PathValue("channelID"), input)
|
||||||
|
if err != nil {
|
||||||
|
if store.IsNotFound(err) {
|
||||||
|
writeError(w, http.StatusNotFound, "file storage channel not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if store.IsUniqueViolation(err) {
|
||||||
|
writeError(w, http.StatusConflict, "file storage channel key already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("update file storage channel failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "update file storage channel failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.store.DeleteFileStorageChannel(r.Context(), r.PathValue("channelID")); err != nil {
|
||||||
|
if store.IsNotFound(err) {
|
||||||
|
writeError(w, http.StatusNotFound, "file storage channel not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("delete file storage channel failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "delete file storage channel failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFileStorageChannelInput(input store.FileStorageChannelInput, existing *store.FileStorageChannel) string {
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(input.Provider))
|
||||||
|
if provider == "" {
|
||||||
|
provider = "server_main_openapi"
|
||||||
|
}
|
||||||
|
status := strings.ToLower(strings.TrimSpace(input.Status))
|
||||||
|
if status == "" {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.ChannelKey) == "" || strings.TrimSpace(input.Name) == "" {
|
||||||
|
return "channelKey and name are required"
|
||||||
|
}
|
||||||
|
if status != "enabled" && status != "disabled" {
|
||||||
|
return "status must be enabled or disabled"
|
||||||
|
}
|
||||||
|
if provider == "server_main_openapi" {
|
||||||
|
hasAPIKey := false
|
||||||
|
if input.APIKey != nil {
|
||||||
|
hasAPIKey = strings.TrimSpace(*input.APIKey) != ""
|
||||||
|
} else if existing != nil {
|
||||||
|
hasAPIKey = strings.TrimSpace(existing.APIKey) != ""
|
||||||
|
}
|
||||||
|
if status == "enabled" && !hasAPIKey {
|
||||||
|
return "server-main OpenAPI channel requires API key before enabling"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@ -52,9 +52,31 @@ func isLocalRateLimitError(err error) bool {
|
|||||||
|
|
||||||
func (s *Service) rateLimitReservations(ctx context.Context, user *auth.User, candidate store.RuntimeModelCandidate, body map[string]any) []store.RateLimitReservation {
|
func (s *Service) rateLimitReservations(ctx context.Context, user *auth.User, candidate store.RuntimeModelCandidate, body map[string]any) []store.RateLimitReservation {
|
||||||
out := make([]store.RateLimitReservation, 0)
|
out := make([]store.RateLimitReservation, 0)
|
||||||
out = append(out, reservationsFromPolicy("platform_model", candidate.PlatformModelID, effectiveRateLimitPolicy(candidate), body)...)
|
out = append(out, reservationsFromPolicy(
|
||||||
|
"platform_model",
|
||||||
|
candidate.PlatformModelID,
|
||||||
|
firstNonEmptyString(candidate.DisplayName, candidate.ModelAlias, candidate.ModelName),
|
||||||
|
map[string]any{
|
||||||
|
"platformId": candidate.PlatformID,
|
||||||
|
"platformName": candidate.PlatformName,
|
||||||
|
"modelAlias": candidate.ModelAlias,
|
||||||
|
"modelName": candidate.ModelName,
|
||||||
|
},
|
||||||
|
effectiveRateLimitPolicy(candidate),
|
||||||
|
body,
|
||||||
|
)...)
|
||||||
if group, err := s.store.ResolveUserGroupPolicy(ctx, user); err == nil && group.ID != "" {
|
if group, err := s.store.ResolveUserGroupPolicy(ctx, user); err == nil && group.ID != "" {
|
||||||
out = append(out, reservationsFromPolicy("user_group", group.ID, group.RateLimitPolicy, body)...)
|
out = append(out, reservationsFromPolicy(
|
||||||
|
"user_group",
|
||||||
|
group.ID,
|
||||||
|
firstNonEmptyString(group.Name, group.GroupKey),
|
||||||
|
map[string]any{
|
||||||
|
"groupKey": group.GroupKey,
|
||||||
|
"name": group.Name,
|
||||||
|
},
|
||||||
|
group.RateLimitPolicy,
|
||||||
|
body,
|
||||||
|
)...)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@ -90,7 +112,7 @@ func effectiveRetryPolicy(candidate store.RuntimeModelCandidate) map[string]any
|
|||||||
return policy
|
return policy
|
||||||
}
|
}
|
||||||
|
|
||||||
func reservationsFromPolicy(scopeType string, scopeKey string, policy map[string]any, body map[string]any) []store.RateLimitReservation {
|
func reservationsFromPolicy(scopeType string, scopeKey string, scopeName string, scopeMetadata map[string]any, policy map[string]any, body map[string]any) []store.RateLimitReservation {
|
||||||
if scopeKey == "" || !hasRules(policy) {
|
if scopeKey == "" || !hasRules(policy) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -108,11 +130,14 @@ func reservationsFromPolicy(scopeType string, scopeKey string, policy map[string
|
|||||||
out = append(out, store.RateLimitReservation{
|
out = append(out, store.RateLimitReservation{
|
||||||
ScopeType: scopeType,
|
ScopeType: scopeType,
|
||||||
ScopeKey: scopeKey,
|
ScopeKey: scopeKey,
|
||||||
|
ScopeName: scopeName,
|
||||||
|
ScopeMetadata: scopeMetadata,
|
||||||
Metric: metric,
|
Metric: metric,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
WindowSeconds: int(floatFromAny(rule["windowSeconds"])),
|
WindowSeconds: int(floatFromAny(rule["windowSeconds"])),
|
||||||
LeaseTTLSeconds: int(floatFromAny(rule["leaseTtlSeconds"])),
|
LeaseTTLSeconds: int(floatFromAny(rule["leaseTtlSeconds"])),
|
||||||
|
Policy: policy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -131,6 +156,11 @@ func estimateRequestTokens(body map[string]any) int {
|
|||||||
if input := stringFromMap(body, "input"); input != "" {
|
if input := stringFromMap(body, "input"); input != "" {
|
||||||
text += input
|
text += input
|
||||||
}
|
}
|
||||||
|
for _, item := range contentItems(body["content"]) {
|
||||||
|
if stringFromAny(item["type"]) == "text" {
|
||||||
|
text += stringFromAny(item["text"])
|
||||||
|
}
|
||||||
|
}
|
||||||
if messages, ok := body["messages"].([]any); ok {
|
if messages, ok := body["messages"].([]any); ok {
|
||||||
for _, raw := range messages {
|
for _, raw := range messages {
|
||||||
message, _ := raw.(map[string]any)
|
message, _ := raw.(map[string]any)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
380
apps/api/internal/runner/param_processor_media.go
Normal file
380
apps/api/internal/runner/param_processor_media.go
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolutionNormalizeProcessor struct{}
|
||||||
|
|
||||||
|
func (resolutionNormalizeProcessor) Name() string { return "ResolutionNormalizeProcessor" }
|
||||||
|
|
||||||
|
func (resolutionNormalizeProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
if stringFromAny(params["resolution"]) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
size := stringFromAny(params["size"])
|
||||||
|
if size == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isImageResolution(modelType, size) || isVideoResolution(modelType, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (resolutionNormalizeProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
size := stringFromAny(params["size"])
|
||||||
|
if stringFromAny(params["resolution"]) == "" && (isImageResolution(modelType, size) || isVideoResolution(modelType, size)) {
|
||||||
|
_, capabilityValue := capabilityEvidence(context.modelCapability, modelType, "output_resolutions")
|
||||||
|
params["resolution"] = size
|
||||||
|
context.resolution = size
|
||||||
|
context.recordChange(
|
||||||
|
"ResolutionNormalizeProcessor",
|
||||||
|
"set",
|
||||||
|
"resolution",
|
||||||
|
nil,
|
||||||
|
size,
|
||||||
|
"size 使用分辨率格式,归一到 resolution 供后续能力校验和计费使用。",
|
||||||
|
capabilityPath(modelType, "output_resolutions"),
|
||||||
|
capabilityValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type aspectRatioProcessor struct{}
|
||||||
|
|
||||||
|
func (aspectRatioProcessor) Name() string { return "AspectRatioProcessor" }
|
||||||
|
|
||||||
|
func (aspectRatioProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
return modelType != "text_generate" && (stringFromAny(params["aspect_ratio"]) != "" || stringFromAny(params["size"]) != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aspectRatioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
capability := capabilityForType(context.modelCapability, modelType)
|
||||||
|
if capability == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
aspectRatio := stringFromAny(params["aspect_ratio"])
|
||||||
|
if isEmptyParamString(aspectRatio) {
|
||||||
|
before := params["aspect_ratio"]
|
||||||
|
delete(params, "aspect_ratio")
|
||||||
|
context.aspectRatio = ""
|
||||||
|
context.recordChange(
|
||||||
|
"AspectRatioProcessor",
|
||||||
|
"remove",
|
||||||
|
"aspect_ratio",
|
||||||
|
before,
|
||||||
|
nil,
|
||||||
|
"aspect_ratio 是空值字符串,不能作为有效比例传给上游。",
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution := firstNonEmptyString(stringFromAny(params["resolution"]), context.resolution)
|
||||||
|
if resolution == "" {
|
||||||
|
if values := stringListFromAny(capability["output_resolutions"]); len(values) > 0 {
|
||||||
|
resolution = values[0]
|
||||||
|
} else if size := stringFromAny(params["size"]); strings.HasSuffix(size, "K") || strings.HasSuffix(size, "p") {
|
||||||
|
resolution = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := aspectRatioAllowed(capability["aspect_ratio_allowed"], resolution)
|
||||||
|
if allowed != nil && len(allowed) == 1 && allowed[0] == "adaptive" {
|
||||||
|
before := params["aspect_ratio"]
|
||||||
|
params["aspect_ratio"] = "adaptive"
|
||||||
|
context.aspectRatio = "adaptive"
|
||||||
|
if before != "adaptive" {
|
||||||
|
context.recordChange(
|
||||||
|
"AspectRatioProcessor",
|
||||||
|
"adjust",
|
||||||
|
"aspect_ratio",
|
||||||
|
before,
|
||||||
|
"adaptive",
|
||||||
|
"模型当前分辨率只允许 adaptive 宽高比。",
|
||||||
|
capabilityPath(modelType, "aspect_ratio_allowed"),
|
||||||
|
capability["aspect_ratio_allowed"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if allowed != nil && len(allowed) == 0 {
|
||||||
|
before := params["aspect_ratio"]
|
||||||
|
delete(params, "aspect_ratio")
|
||||||
|
context.aspectRatio = ""
|
||||||
|
context.recordChange(
|
||||||
|
"AspectRatioProcessor",
|
||||||
|
"remove",
|
||||||
|
"aspect_ratio",
|
||||||
|
before,
|
||||||
|
nil,
|
||||||
|
"模型能力配置不允许传入任何 aspect_ratio。",
|
||||||
|
capabilityPath(modelType, "aspect_ratio_allowed"),
|
||||||
|
capability["aspect_ratio_allowed"],
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if aspectRatio == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if allowed == nil && validAspectRatio(aspectRatio) {
|
||||||
|
params["aspect_ratio"] = aspectRatio
|
||||||
|
context.aspectRatio = aspectRatio
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
processed, ok := validateAndAdjustAspectRatio(aspectRatio, capability, allowed)
|
||||||
|
if !ok {
|
||||||
|
before := params["aspect_ratio"]
|
||||||
|
delete(params, "aspect_ratio")
|
||||||
|
context.aspectRatio = ""
|
||||||
|
context.recordChange(
|
||||||
|
"AspectRatioProcessor",
|
||||||
|
"remove",
|
||||||
|
"aspect_ratio",
|
||||||
|
before,
|
||||||
|
nil,
|
||||||
|
"传入的 aspect_ratio 不在模型允许范围内,且没有可用替代值。",
|
||||||
|
capabilityPath(modelType, "aspect_ratio_allowed"),
|
||||||
|
capability["aspect_ratio_allowed"],
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if processed != "" {
|
||||||
|
before := params["aspect_ratio"]
|
||||||
|
params["aspect_ratio"] = processed
|
||||||
|
context.aspectRatio = processed
|
||||||
|
if before != processed {
|
||||||
|
path := capabilityPath(modelType, "aspect_ratio_allowed")
|
||||||
|
value := capability["aspect_ratio_allowed"]
|
||||||
|
if ratioRange, ok := numberPair(capability["aspect_ratio_range"]); ok {
|
||||||
|
ratio, valid := aspectRatioNumber(aspectRatio)
|
||||||
|
if !valid || ratio < ratioRange[0] || ratio > ratioRange[1] {
|
||||||
|
path = capabilityPath(modelType, "aspect_ratio_range")
|
||||||
|
value = capability["aspect_ratio_range"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.recordChange(
|
||||||
|
"AspectRatioProcessor",
|
||||||
|
"adjust",
|
||||||
|
"aspect_ratio",
|
||||||
|
before,
|
||||||
|
processed,
|
||||||
|
"传入的 aspect_ratio 不符合模型能力配置,已调整为允许值。",
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputAudioProcessor struct{}
|
||||||
|
|
||||||
|
func (inputAudioProcessor) Name() string { return "InputAudioProcessor" }
|
||||||
|
|
||||||
|
func (inputAudioProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
if !isVideoModelType(modelType) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
content := contentItems(params["content"])
|
||||||
|
for _, item := range content {
|
||||||
|
if isAudioContent(item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (inputAudioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
content := contentItems(params["content"])
|
||||||
|
if len(content) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
supportsInputAudio := false
|
||||||
|
if len(context.modelCapability) > 0 {
|
||||||
|
if isOmniVideoLike(context) {
|
||||||
|
supportsInputAudio = supportsOmniAudioReference(context)
|
||||||
|
} else if capability := capabilityForType(context.modelCapability, modelType); capability != nil {
|
||||||
|
supportsInputAudio = boolFromAny(capability["input_audio"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if supportsInputAudio {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
next := make([]map[string]any, 0, len(content))
|
||||||
|
for index, item := range content {
|
||||||
|
if isAudioContent(item) {
|
||||||
|
path, value := audioInputCapabilityEvidence(context, modelType)
|
||||||
|
context.recordChange(
|
||||||
|
"InputAudioProcessor",
|
||||||
|
"remove",
|
||||||
|
fmt.Sprintf("content[%d]", index),
|
||||||
|
item,
|
||||||
|
nil,
|
||||||
|
"模型能力未开启输入音频,已移除 audio_url。",
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, item)
|
||||||
|
}
|
||||||
|
params["content"] = mapsToAnySlice(next)
|
||||||
|
path, value := audioInputCapabilityEvidence(context, modelType)
|
||||||
|
deleteFieldsWithLog(params, context, "InputAudioProcessor", []string{"audio_url", "audioUrl", "reference_audio", "referenceAudio"}, "模型能力未开启输入音频,已移除音频参考快捷字段。", path, value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type durationProcessor struct{}
|
||||||
|
|
||||||
|
func (durationProcessor) Name() string { return "DurationProcessor" }
|
||||||
|
|
||||||
|
func (durationProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
return isVideoModelType(modelType) && params["duration"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (durationProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
capability := capabilityForType(context.modelCapability, modelType)
|
||||||
|
if capability == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
duration := floatFromAny(params["duration"])
|
||||||
|
if duration <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
resolution := firstNonEmptyString(stringFromAny(params["resolution"]), context.resolution)
|
||||||
|
modeKey := videoModeKey(params)
|
||||||
|
if options := scopedNumberList(capability["duration_options"], resolution, modeKey); len(options) > 0 {
|
||||||
|
normalized := nextAllowedNumber(duration, options)
|
||||||
|
params["duration"] = normalized
|
||||||
|
syncDurationSeconds(params)
|
||||||
|
if normalized != duration {
|
||||||
|
context.recordChange(
|
||||||
|
"DurationProcessor",
|
||||||
|
"adjust",
|
||||||
|
"duration",
|
||||||
|
duration,
|
||||||
|
normalized,
|
||||||
|
"duration 不在模型固定时长选项内,已向上调整为允许值。",
|
||||||
|
capabilityPath(modelType, "duration_options"),
|
||||||
|
capability["duration_options"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if minValue, maxValue, ok := scopedRange(capability["duration_range"], resolution, modeKey); ok {
|
||||||
|
step := durationStep(capability["duration_step"], resolution, modeKey)
|
||||||
|
normalized := normalizeDurationByRange(duration, minValue, maxValue, step)
|
||||||
|
params["duration"] = normalized
|
||||||
|
syncDurationSeconds(params)
|
||||||
|
if normalized != duration {
|
||||||
|
context.recordChange(
|
||||||
|
"DurationProcessor",
|
||||||
|
"adjust",
|
||||||
|
"duration",
|
||||||
|
duration,
|
||||||
|
normalized,
|
||||||
|
"duration 超出模型时长范围或步进配置,已按能力配置归一。",
|
||||||
|
capabilityPath(modelType, "duration_range"),
|
||||||
|
map[string]any{
|
||||||
|
"duration_range": capability["duration_range"],
|
||||||
|
"duration_step": capability["duration_step"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
step := durationStep(capability["duration_step"], resolution, modeKey)
|
||||||
|
normalized := normalizeDurationByStep(duration, step)
|
||||||
|
params["duration"] = normalized
|
||||||
|
syncDurationSeconds(params)
|
||||||
|
if normalized != duration {
|
||||||
|
context.recordChange(
|
||||||
|
"DurationProcessor",
|
||||||
|
"adjust",
|
||||||
|
"duration",
|
||||||
|
duration,
|
||||||
|
normalized,
|
||||||
|
"duration 不符合模型时长步进,已按步进向上归一。",
|
||||||
|
capabilityPath(modelType, "duration_step"),
|
||||||
|
capability["duration_step"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type audioProcessor struct{}
|
||||||
|
|
||||||
|
func (audioProcessor) Name() string { return "AudioProcessor" }
|
||||||
|
|
||||||
|
func (audioProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
return isVideoModelType(modelType) && (params["audio"] != nil || params["output_audio"] != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (audioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
capability := capabilityForType(context.modelCapability, modelType)
|
||||||
|
if capability == nil || !boolFromAny(capability["output_audio"]) {
|
||||||
|
for _, key := range []string{"audio", "output_audio"} {
|
||||||
|
if before, ok := params[key]; ok {
|
||||||
|
delete(params, key)
|
||||||
|
context.recordChange(
|
||||||
|
"AudioProcessor",
|
||||||
|
"remove",
|
||||||
|
key,
|
||||||
|
before,
|
||||||
|
nil,
|
||||||
|
"模型能力未开启输出音频,已移除音频输出参数。",
|
||||||
|
capabilityPath(modelType, "output_audio"),
|
||||||
|
capabilityValue(context.modelCapability, modelType, "output_audio"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageCountProcessor struct{}
|
||||||
|
|
||||||
|
func (imageCountProcessor) Name() string { return "ImageCountProcessor" }
|
||||||
|
|
||||||
|
func (imageCountProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
return modelType == "image_generate" || modelType == "image_edit"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (imageCountProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
capability := capabilityForType(context.modelCapability, modelType)
|
||||||
|
if capability == nil || !boolFromAny(capability["output_multiple_images"]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
maxCount := int(math.Round(floatFromAny(capability["output_max_images_count"])))
|
||||||
|
if maxCount <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
count := int(math.Round(floatFromAny(params["n"])))
|
||||||
|
if count <= 0 {
|
||||||
|
count = int(math.Round(floatFromAny(params["batch_size"])))
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
if count > maxCount {
|
||||||
|
before := count
|
||||||
|
count = maxCount
|
||||||
|
context.recordChange(
|
||||||
|
"ImageCountProcessor",
|
||||||
|
"adjust",
|
||||||
|
"n",
|
||||||
|
before,
|
||||||
|
count,
|
||||||
|
"请求图片数量超过模型输出上限,已按 output_max_images_count 截断。",
|
||||||
|
capabilityPath(modelType, "output_max_images_count"),
|
||||||
|
capability["output_max_images_count"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
params["n"] = count
|
||||||
|
return true
|
||||||
|
}
|
||||||
190
apps/api/internal/runner/param_processor_message.go
Normal file
190
apps/api/internal/runner/param_processor_message.go
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type messageContentProcessor struct{}
|
||||||
|
|
||||||
|
func (messageContentProcessor) Name() string { return "MessageContentProcessor" }
|
||||||
|
|
||||||
|
func (messageContentProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
return isTextGenerationKind(context.kind) && params["messages"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (messageContentProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
messages, changed := processMessageListContent(params["messages"], context)
|
||||||
|
if changed {
|
||||||
|
params["messages"] = messages
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func processMessageListContent(value any, context *paramProcessContext) ([]any, bool) {
|
||||||
|
rawMessages, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := make([]any, 0, len(rawMessages))
|
||||||
|
changed := false
|
||||||
|
for messageIndex, rawMessage := range rawMessages {
|
||||||
|
message, ok := rawMessage.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
out = append(out, rawMessage)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextMessage := cloneMap(message)
|
||||||
|
if contentParts, ok := message["content"].([]any); ok {
|
||||||
|
nextContent, contentChanged := processMessageContentParts(
|
||||||
|
contentParts,
|
||||||
|
fmt.Sprintf("messages[%d].content", messageIndex),
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
if contentChanged {
|
||||||
|
nextMessage["content"] = nextContent
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, nextMessage)
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func processMessageContentParts(parts []any, basePath string, context *paramProcessContext) ([]any, bool) {
|
||||||
|
out := make([]any, 0, len(parts))
|
||||||
|
changed := false
|
||||||
|
for partIndex, rawPart := range parts {
|
||||||
|
part, ok := rawPart.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
out = append(out, rawPart)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if replacement, replacementChanged := messageContentPartReplacement(part, context); replacementChanged {
|
||||||
|
out = append(out, replacement)
|
||||||
|
context.recordChange(
|
||||||
|
"MessageContentProcessor",
|
||||||
|
"convert",
|
||||||
|
fmt.Sprintf("%s[%d]", basePath, partIndex),
|
||||||
|
part,
|
||||||
|
replacement,
|
||||||
|
messageContentConversionReason(part),
|
||||||
|
messageContentCapabilityPath(part),
|
||||||
|
messageContentCapabilityValue(part, context),
|
||||||
|
)
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, cloneMap(part))
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageContentPartReplacement(part map[string]any, context *paramProcessContext) (map[string]any, bool) {
|
||||||
|
switch {
|
||||||
|
case isImageContent(part):
|
||||||
|
if modelSupportsMessageModality(context, "image_analysis") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if url := imageURLFromContentPart(part); url != "" {
|
||||||
|
return map[string]any{"type": "text", "text": "Image link: " + url}, true
|
||||||
|
}
|
||||||
|
case isVideoContent(part):
|
||||||
|
if modelSupportsMessageModality(context, "video_understanding") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if url := videoURLFromContentPart(part); url != "" {
|
||||||
|
return map[string]any{"type": "text", "text": "video URL: " + url}, true
|
||||||
|
}
|
||||||
|
case isAudioContent(part) || stringFromAny(part["type"]) == "input_audio":
|
||||||
|
if modelSupportsMessageModality(context, "audio_understanding") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if url := audioURLFromContentPart(part); url != "" {
|
||||||
|
return map[string]any{"type": "text", "text": "audio URL: " + url}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageContentConversionReason(part map[string]any) string {
|
||||||
|
switch {
|
||||||
|
case isImageContent(part):
|
||||||
|
return "模型不支持图像理解,已将 image_url 转为文本链接。"
|
||||||
|
case isVideoContent(part):
|
||||||
|
return "模型不支持视频理解,已将 video_url 转为文本链接。"
|
||||||
|
default:
|
||||||
|
return "模型不支持音频理解,已将音频输入转为文本链接。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageContentCapabilityPath(part map[string]any) string {
|
||||||
|
switch {
|
||||||
|
case isImageContent(part):
|
||||||
|
return "capabilities.image_analysis"
|
||||||
|
case isVideoContent(part):
|
||||||
|
return "capabilities.video_understanding"
|
||||||
|
default:
|
||||||
|
return "capabilities.audio_understanding"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageContentCapabilityValue(part map[string]any, context *paramProcessContext) any {
|
||||||
|
if context == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case isImageContent(part):
|
||||||
|
return capabilityValue(context.modelCapability, "image_analysis", "")
|
||||||
|
case isVideoContent(part):
|
||||||
|
return capabilityValue(context.modelCapability, "video_understanding", "")
|
||||||
|
default:
|
||||||
|
return capabilityValue(context.modelCapability, "audio_understanding", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelSupportsMessageModality(context *paramProcessContext, capabilityName string) bool {
|
||||||
|
if context == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
capabilities := context.modelCapability
|
||||||
|
if capabilityForType(capabilities, capabilityName) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if capabilityForType(capabilities, "omni") != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
originalTypes := stringListFromAny(capabilities["originalTypes"])
|
||||||
|
return containsString(originalTypes, capabilityName) || containsString(originalTypes, "omni")
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageURLFromContentPart(part map[string]any) string {
|
||||||
|
return urlFromNestedContentPart(part, "image_url", "url", "imageUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoURLFromContentPart(part map[string]any) string {
|
||||||
|
return urlFromNestedContentPart(part, "video_url", "url", "videoUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioURLFromContentPart(part map[string]any) string {
|
||||||
|
if stringFromAny(part["type"]) == "input_audio" {
|
||||||
|
if audio, ok := part["input_audio"].(map[string]any); ok {
|
||||||
|
if url := firstNonEmptyString(stringFromAny(audio["data"]), stringFromAny(audio["url"])); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urlFromNestedContentPart(part, "audio_url", "url", "audioUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlFromNestedContentPart(part map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
value := part[key]
|
||||||
|
if url := stringFromAny(value); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if nested, ok := value.(map[string]any); ok {
|
||||||
|
if url := stringFromAny(nested["url"]); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@ -6,6 +6,50 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestVideoModelTypeInferenceReadsContentArray(t *testing.T) {
|
||||||
|
imageToVideo := modelTypeFromKind("videos.generations", map[string]any{
|
||||||
|
"model": "demo-video",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "animate it"},
|
||||||
|
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/frame.png"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if imageToVideo != "image_to_video" {
|
||||||
|
t.Fatalf("image content should infer image_to_video, got %s", imageToVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
omniVideo := modelTypeFromKind("videos.generations", map[string]any{
|
||||||
|
"model": "demo-video",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "edit it"},
|
||||||
|
map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/ref.mp4"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if omniVideo != "omni_video" {
|
||||||
|
t.Fatalf("video content should infer omni_video, got %s", omniVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
textToVideo := modelTypeFromKind("videos.generations", map[string]any{
|
||||||
|
"model": "demo-video",
|
||||||
|
"content": []any{map[string]any{"type": "text", "text": "make a clip"}},
|
||||||
|
})
|
||||||
|
if textToVideo != "video_generate" {
|
||||||
|
t.Fatalf("text-only content should infer video_generate, got %s", textToVideo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoContentTextContributesToTokenEstimate(t *testing.T) {
|
||||||
|
tokens := estimateRequestTokens(map[string]any{
|
||||||
|
"model": "demo-video",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "a cinematic product reveal"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if tokens <= 1 {
|
||||||
|
t.Fatalf("content text should contribute to token estimate, got %d", tokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParamProcessorOmniFiltersUnsupportedVideoAndAudioContent(t *testing.T) {
|
func TestParamProcessorOmniFiltersUnsupportedVideoAndAudioContent(t *testing.T) {
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"model": "可灵O1",
|
"model": "可灵O1",
|
||||||
@ -123,6 +167,163 @@ func TestParamProcessorOmniCapabilityLogUsesActualCapabilityKey(t *testing.T) {
|
|||||||
t.Fatalf("expected log to reference capabilities.omni.input_audio, got %+v", result.Log.Changes)
|
t.Fatalf("expected log to reference capabilities.omni.input_audio, got %+v", result.Log.Changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorChatConvertsUnsupportedMediaMessageContentToText(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "text-only",
|
||||||
|
"messages": []any{
|
||||||
|
map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "describe these"},
|
||||||
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/image.png"}},
|
||||||
|
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/video.mp4"}},
|
||||||
|
map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": "https://example.com/audio.mp3"}},
|
||||||
|
map[string]any{"type": "input_audio", "input_audio": map[string]any{"data": "https://example.com/input.wav"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "text_generate",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"text_generate": map[string]any{},
|
||||||
|
"originalTypes": []any{"text_generate"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("chat.completions", body, candidate)
|
||||||
|
messages, _ := result.Body["messages"].([]any)
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("expected one message, got %+v", result.Body["messages"])
|
||||||
|
}
|
||||||
|
message, _ := messages[0].(map[string]any)
|
||||||
|
content, _ := message["content"].([]any)
|
||||||
|
if len(content) != 5 {
|
||||||
|
t.Fatalf("expected five content parts, got %+v", message["content"])
|
||||||
|
}
|
||||||
|
expectedText := []string{
|
||||||
|
"describe these",
|
||||||
|
"Image link: https://example.com/image.png",
|
||||||
|
"video URL: https://example.com/video.mp4",
|
||||||
|
"audio URL: https://example.com/audio.mp3",
|
||||||
|
"audio URL: https://example.com/input.wav",
|
||||||
|
}
|
||||||
|
for index, expected := range expectedText {
|
||||||
|
part, _ := content[index].(map[string]any)
|
||||||
|
if stringFromAny(part["text"]) != expected {
|
||||||
|
t.Fatalf("content[%d] text = %q, want %q; all=%+v", index, stringFromAny(part["text"]), expected, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result.Log.Changes) != 4 {
|
||||||
|
t.Fatalf("expected four media conversion changes, got %+v", result.Log.Changes)
|
||||||
|
}
|
||||||
|
expectedCapabilityPaths := map[string]bool{
|
||||||
|
"capabilities.image_analysis": false,
|
||||||
|
"capabilities.video_understanding": false,
|
||||||
|
"capabilities.audio_understanding": false,
|
||||||
|
}
|
||||||
|
for _, change := range result.Log.Changes {
|
||||||
|
if _, ok := expectedCapabilityPaths[change.CapabilityPath]; ok {
|
||||||
|
expectedCapabilityPaths[change.CapabilityPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path, found := range expectedCapabilityPaths {
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected conversion log for %s, got %+v", path, result.Log.Changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorChatKeepsOmniMessageContent(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "omni",
|
||||||
|
"messages": []any{
|
||||||
|
map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/image.png"}},
|
||||||
|
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/video.mp4"}},
|
||||||
|
map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": "https://example.com/audio.mp3"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "text_generate",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"text_generate": map[string]any{},
|
||||||
|
"omni": map[string]any{},
|
||||||
|
"originalTypes": []any{"text_generate", "omni"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("chat.completions", body, candidate)
|
||||||
|
if result.Log.Changed {
|
||||||
|
t.Fatalf("omni model should keep message media content unchanged, got %+v", result.Log.Changes)
|
||||||
|
}
|
||||||
|
messages, _ := result.Body["messages"].([]any)
|
||||||
|
message, _ := messages[0].(map[string]any)
|
||||||
|
content, _ := message["content"].([]any)
|
||||||
|
for _, item := range content {
|
||||||
|
part, _ := item.(map[string]any)
|
||||||
|
if stringFromAny(part["type"]) == "text" {
|
||||||
|
t.Fatalf("media content should not be converted for omni model: %+v", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorChatConvertsOnlyUnsupportedModalities(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "vision-only",
|
||||||
|
"messages": []any{
|
||||||
|
map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/image.png"}},
|
||||||
|
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/video.mp4"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "text_generate",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"text_generate": map[string]any{},
|
||||||
|
"image_analysis": map[string]any{},
|
||||||
|
"originalTypes": []any{"text_generate", "image_analysis"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("chat.completions", body, candidate)
|
||||||
|
messages, _ := result.Body["messages"].([]any)
|
||||||
|
message, _ := messages[0].(map[string]any)
|
||||||
|
content, _ := message["content"].([]any)
|
||||||
|
first, _ := content[0].(map[string]any)
|
||||||
|
second, _ := content[1].(map[string]any)
|
||||||
|
if stringFromAny(first["type"]) != "image_url" {
|
||||||
|
t.Fatalf("image content should be kept when image_analysis is supported: %+v", content)
|
||||||
|
}
|
||||||
|
if stringFromAny(second["text"]) != "video URL: https://example.com/video.mp4" {
|
||||||
|
t.Fatalf("video content should be converted, got %+v", second)
|
||||||
|
}
|
||||||
|
if len(result.Log.Changes) != 1 || result.Log.Changes[0].CapabilityPath != "capabilities.video_understanding" {
|
||||||
|
t.Fatalf("expected only video conversion to be logged, got %+v", result.Log.Changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipTaskParameterPreprocessingLogForTextModelTypes(t *testing.T) {
|
||||||
|
for _, modelType := range []string{"text_generate", "chat", "responses", "text"} {
|
||||||
|
if !skipTaskParameterPreprocessingLog(modelType) {
|
||||||
|
t.Fatalf("%s should skip task parameter preprocessing log", modelType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, modelType := range []string{"image_generate", "image_edit", "video_generate", "omni_video"} {
|
||||||
|
if skipTaskParameterPreprocessingLog(modelType) {
|
||||||
|
t.Fatalf("%s should keep task parameter preprocessing log", modelType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParamProcessorVideoCapabilitiesNormalizeAndFilter(t *testing.T) {
|
func TestParamProcessorVideoCapabilitiesNormalizeAndFilter(t *testing.T) {
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"model": "Seedance",
|
"model": "Seedance",
|
||||||
@ -180,6 +381,222 @@ func TestParamProcessorVideoCapabilitiesNormalizeAndFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorDowngradesReferenceImagesToFrames(t *testing.T) {
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"image_to_video": map[string]any{
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": true,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "animate it"},
|
||||||
|
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||||
|
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/last.png"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("two image references should downgrade to first/last frames: %v", result.Err)
|
||||||
|
}
|
||||||
|
content := contentItems(result.Body["content"])
|
||||||
|
if stringFromAny(content[1]["role"]) != "first_frame" || stringFromAny(content[2]["role"]) != "last_frame" {
|
||||||
|
t.Fatalf("expected first/last frame downgrade, got %+v", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorDowngradesSingleReferenceImageToFirstFrame(t *testing.T) {
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"image_to_video": map[string]any{
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": true,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "animate it"},
|
||||||
|
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("single image reference should downgrade to first frame: %v", result.Err)
|
||||||
|
}
|
||||||
|
content := contentItems(result.Body["content"])
|
||||||
|
if stringFromAny(content[1]["role"]) != "first_frame" {
|
||||||
|
t.Fatalf("expected first frame downgrade, got %+v", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorRejectsUnsafeReferenceImageDowngrade(t *testing.T) {
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"image_to_video": map[string]any{
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": false,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "animate it"},
|
||||||
|
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||||
|
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/last.png"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Err == nil {
|
||||||
|
t.Fatalf("two image references should be rejected when first/last frame is unsupported")
|
||||||
|
}
|
||||||
|
if len(result.Log.Changes) == 0 || result.Log.Changes[len(result.Log.Changes)-1].Action != "reject" {
|
||||||
|
t.Fatalf("expected reject preprocessing log, got %+v", result.Log.Changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorRejectsVideoOrAudioReferenceDowngrade(t *testing.T) {
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"image_to_video": map[string]any{
|
||||||
|
"input_first_frame": true,
|
||||||
|
"input_first_last_frame": true,
|
||||||
|
"input_reference_generate_single": false,
|
||||||
|
"input_reference_generate_multiple": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "animate it"},
|
||||||
|
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||||
|
map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/ref.mp4"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Err == nil {
|
||||||
|
t.Fatalf("video reference should be rejected instead of downgraded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorDurationRangeRoundsFractionalSecondsUp(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"duration": 5.5,
|
||||||
|
}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "video_generate",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"video_generate": map[string]any{
|
||||||
|
"duration_range": []any{3, 12},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Body["duration"] != float64(6) && result.Body["duration"] != 6 {
|
||||||
|
t.Fatalf("fractional duration should be rounded up to default 1s step, got %+v", result.Body["duration"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorDurationWithoutRangeStillRoundsUp(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"duration": 5.2,
|
||||||
|
}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "video_generate",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"video_generate": map[string]any{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Body["duration"] != float64(6) && result.Body["duration"] != 6 {
|
||||||
|
t.Fatalf("duration should default to a 1s upward step without range, got %+v", result.Body["duration"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorDurationRangeUsesStepCeilingAndRange(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"duration": 6.1,
|
||||||
|
"duration_seconds": 6.1,
|
||||||
|
}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"image_to_video": map[string]any{
|
||||||
|
"duration_range": []any{5, 10},
|
||||||
|
"duration_step": 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Body["duration"] != float64(7) && result.Body["duration"] != 7 {
|
||||||
|
t.Fatalf("duration should be rounded up by configured step, got %+v", result.Body["duration"])
|
||||||
|
}
|
||||||
|
if result.Body["duration_seconds"] != result.Body["duration"] {
|
||||||
|
t.Fatalf("duration_seconds should sync with normalized duration, got %+v", result.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
body["duration"] = 10.1
|
||||||
|
body["duration_seconds"] = 10.1
|
||||||
|
result = preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Body["duration"] != float64(10) && result.Body["duration"] != 10 {
|
||||||
|
t.Fatalf("duration should be capped by range max, got %+v", result.Body["duration"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamProcessorDurationOptionsChooseNextAllowedValue(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "Seedance",
|
||||||
|
"prompt": "animate it",
|
||||||
|
"duration": 8.1,
|
||||||
|
}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Capabilities: map[string]any{
|
||||||
|
"image_to_video": map[string]any{
|
||||||
|
"duration_options": []any{4, 8, 12},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := preprocessRequestWithLog("videos.generations", body, candidate)
|
||||||
|
if result.Body["duration"] != float64(12) && result.Body["duration"] != 12 {
|
||||||
|
t.Fatalf("duration should use next allowed option, got %+v", result.Body["duration"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParamProcessorVideoGenerateLogsFirstFrameRemoval(t *testing.T) {
|
func TestParamProcessorVideoGenerateLogsFirstFrameRemoval(t *testing.T) {
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"model": "Seedance T2V",
|
"model": "Seedance T2V",
|
||||||
|
|||||||
511
apps/api/internal/runner/param_processor_utils.go
Normal file
511
apps/api/internal/runner/param_processor_utils.go
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateAndAdjustAspectRatio(aspectRatio string, capability map[string]any, allowed []string) (string, bool) {
|
||||||
|
if !isMediaModelTypeWithAspectRatio(capability) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if ratioRange, ok := numberPair(capability["aspect_ratio_range"]); ok {
|
||||||
|
ratio, valid := aspectRatioNumber(aspectRatio)
|
||||||
|
if !valid || ratio < ratioRange[0] || ratio > ratioRange[1] {
|
||||||
|
return adjustAspectRatioToRange(aspectRatio, ratioRange[0], ratioRange[1], allowed), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed == nil {
|
||||||
|
return aspectRatio, true
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if (aspectRatio == "adaptive" || aspectRatio == "keep_ratio") && !containsString(allowed, aspectRatio) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if containsString(allowed, aspectRatio) {
|
||||||
|
return aspectRatio, true
|
||||||
|
}
|
||||||
|
return allowed[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMediaModelTypeWithAspectRatio(capability map[string]any) bool {
|
||||||
|
return capability != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aspectRatioAllowed(value any, resolution string) []string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
return stringListFromAny(typed)
|
||||||
|
case []string:
|
||||||
|
return typed
|
||||||
|
case map[string]any:
|
||||||
|
if resolution != "" {
|
||||||
|
if values := stringListFromAny(typed[resolution]); len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopedNumberList(value any, scopes ...string) []float64 {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]float64, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if number := floatFromAny(item); number > 0 {
|
||||||
|
out = append(out, number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []float64:
|
||||||
|
return typed
|
||||||
|
case []int:
|
||||||
|
out := make([]float64, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
out = append(out, float64(item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case map[string]any:
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if values := scopedNumberList(typed[scope]); len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range typed {
|
||||||
|
if values := scopedNumberList(item); len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopedRange(value any, scopes ...string) (float64, float64, bool) {
|
||||||
|
if pair, ok := numberPair(value); ok {
|
||||||
|
return pair[0], pair[1], true
|
||||||
|
}
|
||||||
|
if typed, ok := value.(map[string]any); ok {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if minValue, maxValue, ok := scopedRange(typed[scope]); ok {
|
||||||
|
return minValue, maxValue, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range typed {
|
||||||
|
if minValue, maxValue, ok := scopedRange(item); ok {
|
||||||
|
return minValue, maxValue, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationStep(value any, scopes ...string) float64 {
|
||||||
|
if step := floatFromAny(value); step > 0 {
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
if typed, ok := value.(map[string]any); ok {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if step := durationStep(typed[scope]); step > 0 {
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range typed {
|
||||||
|
if step := durationStep(item); step > 0 {
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDurationByRange(target float64, minValue float64, maxValue float64, step float64) float64 {
|
||||||
|
if minValue > maxValue {
|
||||||
|
minValue, maxValue = maxValue, minValue
|
||||||
|
}
|
||||||
|
if step <= 0 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
clamped := math.Min(math.Max(target, minValue), maxValue)
|
||||||
|
snapped := math.Ceil(((clamped-minValue)/step)-1e-9)*step + minValue
|
||||||
|
snapped = math.Min(math.Max(snapped, minValue), maxValue)
|
||||||
|
return math.Round(snapped*1_000_000) / 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDurationByStep(target float64, step float64) float64 {
|
||||||
|
if step <= 0 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
snapped := math.Ceil((target/step)-1e-9) * step
|
||||||
|
return math.Round(snapped*1_000_000) / 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextAllowedNumber(target float64, values []float64) float64 {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
sorted := append([]float64(nil), values...)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
for _, value := range sorted {
|
||||||
|
if value >= target || math.Abs(value-target) < 1e-9 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sorted[len(sorted)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentItems(value any) []map[string]any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if object, ok := item.(map[string]any); ok {
|
||||||
|
out = append(out, cloneMap(object))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []map[string]any:
|
||||||
|
out := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
out = append(out, cloneMap(item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapsToAnySlice(values []map[string]any) []any {
|
||||||
|
out := make([]any, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageContent(item map[string]any) bool {
|
||||||
|
return stringFromAny(item["type"]) == "image_url" || item["image_url"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVideoContent(item map[string]any) bool {
|
||||||
|
return stringFromAny(item["type"]) == "video_url" || item["video_url"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAudioContent(item map[string]any) bool {
|
||||||
|
return stringFromAny(item["type"]) == "audio_url" || item["audio_url"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capabilityForType(capabilities map[string]any, modelType string) map[string]any {
|
||||||
|
if capabilities == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if typed, ok := capabilities[modelType].(map[string]any); ok {
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capabilityPath(modelType string, key string) string {
|
||||||
|
modelType = strings.TrimSpace(modelType)
|
||||||
|
if modelType == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
return "capabilities." + modelType
|
||||||
|
}
|
||||||
|
return "capabilities." + modelType + "." + key
|
||||||
|
}
|
||||||
|
|
||||||
|
func capabilityValue(capabilities map[string]any, modelType string, key string) any {
|
||||||
|
capability := capabilityForType(capabilities, modelType)
|
||||||
|
if capability == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
return cloneMap(capability)
|
||||||
|
}
|
||||||
|
return cloneAny(capability[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
func capabilityEvidence(capabilities map[string]any, modelType string, key string) (string, any) {
|
||||||
|
return capabilityPath(modelType, key), capabilityValue(capabilities, modelType, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioInputCapabilityEvidence(context *paramProcessContext, modelType string) (string, any) {
|
||||||
|
if isOmniVideoLike(context) {
|
||||||
|
path, value := omniCapabilityEvidence(context, "input_audio")
|
||||||
|
return path, mergeMetrics(map[string]any{"input_audio": value}, omniCapabilityBundle(context, "max_audios"))
|
||||||
|
}
|
||||||
|
return capabilityEvidence(context.modelCapability, modelType, "input_audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
func omniCapabilityType(context *paramProcessContext) string {
|
||||||
|
if context != nil && capabilityForType(context.modelCapability, "omni_video") != nil {
|
||||||
|
return "omni_video"
|
||||||
|
}
|
||||||
|
if context != nil && capabilityForType(context.modelCapability, "omni") != nil {
|
||||||
|
return "omni"
|
||||||
|
}
|
||||||
|
return "omni_video"
|
||||||
|
}
|
||||||
|
|
||||||
|
func omniCapabilityEvidence(context *paramProcessContext, key string) (string, any) {
|
||||||
|
modelType := omniCapabilityType(context)
|
||||||
|
var capabilities map[string]any
|
||||||
|
if context != nil {
|
||||||
|
capabilities = context.modelCapability
|
||||||
|
}
|
||||||
|
return capabilityPath(modelType, key), capabilityValue(capabilities, modelType, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func omniCapabilityBundle(context *paramProcessContext, keys ...string) map[string]any {
|
||||||
|
modelType := omniCapabilityType(context)
|
||||||
|
var capabilities map[string]any
|
||||||
|
if context != nil {
|
||||||
|
capabilities = context.modelCapability
|
||||||
|
}
|
||||||
|
out := map[string]any{}
|
||||||
|
for _, key := range keys {
|
||||||
|
out[key] = capabilityValue(capabilities, modelType, key)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericField(values map[string]any, key string) (float64, bool) {
|
||||||
|
if values == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if _, ok := values[key]; !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return floatFromAny(values[key]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolFromAny(value any) bool {
|
||||||
|
typed, _ := value.(bool)
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStringValue(values map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := stringFromAny(values[key]); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStringListFromAny(values ...any) []string {
|
||||||
|
for _, value := range values {
|
||||||
|
items := stringListFromAny(value)
|
||||||
|
if len(items) > 0 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringListFromAny(value any) []string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []string:
|
||||||
|
out := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if text := strings.TrimSpace(item); text != "" {
|
||||||
|
out = append(out, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if text := stringFromAny(item); text != "" {
|
||||||
|
out = append(out, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(typed) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{strings.TrimSpace(typed)}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, target string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueString(values *[]string, value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, existing := range *values {
|
||||||
|
if existing == value {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*values = append(*values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberPair(value any) ([2]float64, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
if len(typed) < 2 {
|
||||||
|
return [2]float64{}, false
|
||||||
|
}
|
||||||
|
return [2]float64{floatFromAny(typed[0]), floatFromAny(typed[1])}, true
|
||||||
|
case []float64:
|
||||||
|
if len(typed) < 2 {
|
||||||
|
return [2]float64{}, false
|
||||||
|
}
|
||||||
|
return [2]float64{typed[0], typed[1]}, true
|
||||||
|
case []int:
|
||||||
|
if len(typed) < 2 {
|
||||||
|
return [2]float64{}, false
|
||||||
|
}
|
||||||
|
return [2]float64{float64(typed[0]), float64(typed[1])}, true
|
||||||
|
default:
|
||||||
|
return [2]float64{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAspectRatio(value string) bool {
|
||||||
|
if value == "adaptive" || value == "keep_ratio" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, ok := aspectRatioNumber(value)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func aspectRatioNumber(value string) (float64, bool) {
|
||||||
|
parts := strings.Split(value, ":")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
width := parsePositiveFloat(parts[0])
|
||||||
|
height := parsePositiveFloat(parts[1])
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return width / height, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjustAspectRatioToRange(value string, minValue float64, maxValue float64, allowed []string) string {
|
||||||
|
current, ok := aspectRatioNumber(value)
|
||||||
|
if !ok {
|
||||||
|
if len(allowed) > 0 {
|
||||||
|
return allowed[0]
|
||||||
|
}
|
||||||
|
return "1:1"
|
||||||
|
}
|
||||||
|
if len(allowed) > 0 {
|
||||||
|
closest := ""
|
||||||
|
minDiff := math.Inf(1)
|
||||||
|
for _, candidate := range allowed {
|
||||||
|
ratio, ok := aspectRatioNumber(candidate)
|
||||||
|
if !ok || ratio < minValue || ratio > maxValue {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diff := math.Abs(ratio - current)
|
||||||
|
if diff < minDiff {
|
||||||
|
minDiff = diff
|
||||||
|
closest = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closest != "" {
|
||||||
|
return closest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < minValue {
|
||||||
|
return ratioString(minValue)
|
||||||
|
}
|
||||||
|
return ratioString(maxValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratioString(value float64) string {
|
||||||
|
if value <= 0 {
|
||||||
|
return "1:1"
|
||||||
|
}
|
||||||
|
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 6, 64), "0"), ".") + ":1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositiveFloat(value string) float64 {
|
||||||
|
for _, r := range strings.TrimSpace(value) {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
if r != '.' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, _ := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyParamString(value string) bool {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
return normalized == "null" || normalized == "undefined"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageResolution(modelType string, value string) bool {
|
||||||
|
return (modelType == "image_generate" || modelType == "image_edit") && containsString([]string{"1K", "2K", "4K", "8K"}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVideoResolution(modelType string, value string) bool {
|
||||||
|
return isVideoModelType(modelType) && containsString([]string{"480p", "720p", "1080p", "1440p", "2160p"}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVideoModelType(modelType string) bool {
|
||||||
|
return modelType == "video_generate" || modelType == "text_to_video" || modelType == "image_to_video" || modelType == "video_edit" || modelType == "video_reference" || modelType == "video_first_last_frame" || modelType == "omni_video" || modelType == "omni"
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMap(values map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for key, value := range values {
|
||||||
|
out[key] = cloneAny(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAny(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return cloneMap(typed)
|
||||||
|
case []any:
|
||||||
|
out := make([]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
out = append(out, cloneAny(item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []map[string]any:
|
||||||
|
out := make([]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
out = append(out, cloneMap(item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
663
apps/api/internal/runner/param_processor_video_content.go
Normal file
663
apps/api/internal/runner/param_processor_video_content.go
Normal file
@ -0,0 +1,663 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contentFilterProcessor struct{}
|
||||||
|
|
||||||
|
func (contentFilterProcessor) Name() string { return "ContentFilterProcessor" }
|
||||||
|
|
||||||
|
func (contentFilterProcessor) ShouldProcess(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
_, ok := params["content"]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (contentFilterProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||||
|
content := contentItems(params["content"])
|
||||||
|
if len(content) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOmniVideoLike(context) {
|
||||||
|
filtered := filterUnsupportedOmniVideoContent(content, context)
|
||||||
|
params["content"] = mapsToAnySlice(filtered)
|
||||||
|
syncVideoConvenienceFields(params, filtered, context)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := downgradeReferenceImageIfNeeded(params, content, modelType, context); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if modelType == "video_generate" || modelType == "text_to_video" {
|
||||||
|
next := make([]map[string]any, 0, len(content))
|
||||||
|
for index, item := range content {
|
||||||
|
if isImageContent(item) {
|
||||||
|
reason, path, value := imageContentRemovalEvidence(item, modelType, context)
|
||||||
|
context.recordChange(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"remove",
|
||||||
|
fmt.Sprintf("content[%d]", index),
|
||||||
|
item,
|
||||||
|
nil,
|
||||||
|
reason,
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, item)
|
||||||
|
}
|
||||||
|
content = next
|
||||||
|
}
|
||||||
|
if modelType == "image_to_video" || modelType == "omni_video" || modelType == "omni" {
|
||||||
|
if !supportsFirstAndLastFrame(context.modelCapability, modelType) {
|
||||||
|
next := make([]map[string]any, 0, len(content))
|
||||||
|
for index, item := range content {
|
||||||
|
if stringFromAny(item["role"]) == "last_frame" {
|
||||||
|
context.recordChange(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"remove",
|
||||||
|
fmt.Sprintf("content[%d]", index),
|
||||||
|
item,
|
||||||
|
nil,
|
||||||
|
"模型不支持首尾帧输入,已移除 last_frame。",
|
||||||
|
capabilityPath(modelType, "input_first_last_frame"),
|
||||||
|
map[string]any{
|
||||||
|
"input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"),
|
||||||
|
"max_images_for_last_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_last_frame"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, item)
|
||||||
|
}
|
||||||
|
content = next
|
||||||
|
deleteFieldsWithLog(params, context, "ContentFilterProcessor", []string{"last_frame", "lastFrame"}, "模型不支持首尾帧输入,已移除快捷字段。", capabilityPath(modelType, "input_first_last_frame"), map[string]any{
|
||||||
|
"input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"),
|
||||||
|
"max_images_for_last_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_last_frame"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params["content"] = mapsToAnySlice(content)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageContentRemovalEvidence(item map[string]any, modelType string, context *paramProcessContext) (string, string, any) {
|
||||||
|
role := stringFromAny(item["role"])
|
||||||
|
switch role {
|
||||||
|
case "first_frame":
|
||||||
|
return "模型能力未开启首帧输入,已移除 first_frame。", capabilityPath(modelType, "input_first_frame"), map[string]any{
|
||||||
|
"input_first_frame": capabilityValue(context.modelCapability, modelType, "input_first_frame"),
|
||||||
|
"input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"),
|
||||||
|
}
|
||||||
|
case "last_frame":
|
||||||
|
return "模型能力未开启尾帧或首尾帧输入,已移除 last_frame。", capabilityPath(modelType, "input_first_last_frame"), map[string]any{
|
||||||
|
"input_last_frame": capabilityValue(context.modelCapability, modelType, "input_last_frame"),
|
||||||
|
"input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"),
|
||||||
|
"max_images_for_last_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_last_frame"),
|
||||||
|
"max_images_for_first_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_first_frame"),
|
||||||
|
"max_images_for_middle_frame": capabilityValue(context.modelCapability, modelType, "max_images_for_middle_frame"),
|
||||||
|
}
|
||||||
|
case "reference_image":
|
||||||
|
return "模型能力未开启参考图输入,已移除 reference_image。", capabilityPath(modelType, "input_reference_generate_single"), map[string]any{
|
||||||
|
"input_reference_generate_single": capabilityValue(context.modelCapability, modelType, "input_reference_generate_single"),
|
||||||
|
"input_reference_generate_multiple": capabilityValue(context.modelCapability, modelType, "input_reference_generate_multiple"),
|
||||||
|
"max_images": capabilityValue(context.modelCapability, modelType, "max_images"),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "当前模型能力未开启图像输入,已移除 image_url。", capabilityPath(modelType, "input_first_frame"), map[string]any{
|
||||||
|
"input_first_frame": capabilityValue(context.modelCapability, modelType, "input_first_frame"),
|
||||||
|
"input_first_last_frame": capabilityValue(context.modelCapability, modelType, "input_first_last_frame"),
|
||||||
|
"input_reference_generate_single": capabilityValue(context.modelCapability, modelType, "input_reference_generate_single"),
|
||||||
|
"input_reference_generate_multiple": capabilityValue(context.modelCapability, modelType, "input_reference_generate_multiple"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureVideoContent(params map[string]any, context *paramProcessContext) {
|
||||||
|
if len(contentItems(params["content"])) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content := make([]map[string]any, 0)
|
||||||
|
if prompt := firstNonEmptyString(stringFromAny(params["prompt"]), stringFromAny(params["input"])); prompt != "" {
|
||||||
|
content = append(content, map[string]any{"type": "text", "text": prompt})
|
||||||
|
}
|
||||||
|
appendURL := func(kind string, role string, url string) {
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
if url == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := map[string]any{"type": kind, "role": role}
|
||||||
|
switch kind {
|
||||||
|
case "image_url":
|
||||||
|
item["image_url"] = map[string]any{"url": url}
|
||||||
|
case "video_url":
|
||||||
|
item["video_url"] = map[string]any{"url": url}
|
||||||
|
case "audio_url":
|
||||||
|
item["audio_url"] = map[string]any{"url": url}
|
||||||
|
}
|
||||||
|
content = append(content, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstFrame := firstNonEmptyStringValue(params, "first_frame", "firstFrame")
|
||||||
|
appendURL("image_url", "first_frame", firstFrame)
|
||||||
|
appendURL("image_url", "last_frame", firstNonEmptyStringValue(params, "last_frame", "lastFrame"))
|
||||||
|
imageURLs := firstNonEmptyStringListFromAny(params["image"], params["images"], params["image_url"], params["imageUrl"], params["image_urls"], params["imageUrls"])
|
||||||
|
if firstFrame == "" && len(imageURLs) > 0 {
|
||||||
|
appendURL("image_url", "first_frame", imageURLs[0])
|
||||||
|
imageURLs = imageURLs[1:]
|
||||||
|
}
|
||||||
|
for _, url := range imageURLs {
|
||||||
|
appendURL("image_url", "reference_image", url)
|
||||||
|
}
|
||||||
|
for _, url := range firstNonEmptyStringListFromAny(params["reference_image"], params["referenceImage"]) {
|
||||||
|
appendURL("image_url", "reference_image", url)
|
||||||
|
}
|
||||||
|
for _, url := range firstNonEmptyStringListFromAny(params["video"], params["video_url"], params["videoUrl"], params["reference_video"], params["referenceVideo"]) {
|
||||||
|
appendURL("video_url", "reference_video", url)
|
||||||
|
}
|
||||||
|
for _, url := range firstNonEmptyStringListFromAny(params["audio_url"], params["audioUrl"], params["reference_audio"], params["referenceAudio"]) {
|
||||||
|
appendURL("audio_url", "reference_audio", url)
|
||||||
|
}
|
||||||
|
if len(content) > 0 {
|
||||||
|
params["content"] = mapsToAnySlice(content)
|
||||||
|
context.recordChange(
|
||||||
|
"ContentBuildProcessor",
|
||||||
|
"set",
|
||||||
|
"content",
|
||||||
|
nil,
|
||||||
|
params["content"],
|
||||||
|
"将 prompt/first_frame/reference_* 等快捷字段转换为 content 数组,后续处理器可按模型能力逐项过滤。",
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveModelCapability(candidate store.RuntimeModelCandidate) map[string]any {
|
||||||
|
base := cloneMap(candidate.Capabilities)
|
||||||
|
for key, value := range candidate.CapabilityOverride {
|
||||||
|
if baseChild, ok := base[key].(map[string]any); ok {
|
||||||
|
if overrideChild, ok := value.(map[string]any); ok {
|
||||||
|
base[key] = mergeMap(baseChild, overrideChild)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base[key] = cloneAny(value)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterUnsupportedOmniVideoContent(content []map[string]any, context *paramProcessContext) []map[string]any {
|
||||||
|
capability := omniVideoCapability(context)
|
||||||
|
maxVideos := math.Inf(1)
|
||||||
|
if capability != nil {
|
||||||
|
if value, ok := numericField(capability, "max_videos"); ok {
|
||||||
|
maxVideos = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxAudios := 0.0
|
||||||
|
if capability != nil {
|
||||||
|
if value, ok := numericField(capability, "max_audios"); ok {
|
||||||
|
maxAudios = value
|
||||||
|
} else if supportsOmniAudioReference(context) {
|
||||||
|
maxAudios = math.Inf(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoCount := 0.0
|
||||||
|
audioCount := 0.0
|
||||||
|
out := make([]map[string]any, 0, len(content))
|
||||||
|
for index, item := range content {
|
||||||
|
if isVideoContent(item) {
|
||||||
|
if !supportsOmniVideoReference(item, capability) {
|
||||||
|
path, value := omniCapabilityEvidence(context, "supported_modes")
|
||||||
|
context.recordChange(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"remove",
|
||||||
|
fmt.Sprintf("content[%d]", index),
|
||||||
|
item,
|
||||||
|
nil,
|
||||||
|
"视频参考类型不在 omni_video.supported_modes 允许范围内。",
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if videoCount >= maxVideos {
|
||||||
|
path, value := omniCapabilityEvidence(context, "max_videos")
|
||||||
|
context.recordChange(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"remove",
|
||||||
|
fmt.Sprintf("content[%d]", index),
|
||||||
|
item,
|
||||||
|
nil,
|
||||||
|
"视频参考数量超过 omni_video.max_videos 限制。",
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
videoCount++
|
||||||
|
out = append(out, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isAudioContent(item) {
|
||||||
|
if !supportsOmniAudioReference(context) {
|
||||||
|
path, value := omniCapabilityEvidence(context, "input_audio")
|
||||||
|
context.recordChange(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"remove",
|
||||||
|
fmt.Sprintf("content[%d]", index),
|
||||||
|
item,
|
||||||
|
nil,
|
||||||
|
"模型能力不支持音频参考,已移除 audio_url。",
|
||||||
|
path,
|
||||||
|
mergeMetrics(map[string]any{"input_audio": value}, omniCapabilityBundle(context, "max_audios")),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if audioCount >= maxAudios {
|
||||||
|
path, value := omniCapabilityEvidence(context, "max_audios")
|
||||||
|
context.recordChange(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"remove",
|
||||||
|
fmt.Sprintf("content[%d]", index),
|
||||||
|
item,
|
||||||
|
nil,
|
||||||
|
"音频参考数量超过 omni_video.max_audios 限制。",
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
audioCount++
|
||||||
|
out = append(out, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOmniVideoLike(context *paramProcessContext) bool {
|
||||||
|
modelType := strings.TrimSpace(context.candidate.ModelType)
|
||||||
|
return modelType == "omni_video" ||
|
||||||
|
modelType == "omni" ||
|
||||||
|
context.modelCapability["omni_video"] != nil ||
|
||||||
|
context.modelCapability["omni"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func omniVideoCapability(context *paramProcessContext) map[string]any {
|
||||||
|
if capability := capabilityForType(context.modelCapability, "omni_video"); capability != nil {
|
||||||
|
return capability
|
||||||
|
}
|
||||||
|
return capabilityForType(context.modelCapability, "omni")
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsOmniAudioReference(context *paramProcessContext) bool {
|
||||||
|
capability := omniVideoCapability(context)
|
||||||
|
return capability != nil && (boolFromAny(capability["input_audio"]) || floatFromAny(capability["max_audios"]) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsOmniVideoReference(item map[string]any, capability map[string]any) bool {
|
||||||
|
if capability == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if value, ok := numericField(capability, "max_videos"); ok && value == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
supportedModes := stringListFromAny(capability["supported_modes"])
|
||||||
|
supportsReference := containsString(supportedModes, "video_reference")
|
||||||
|
supportsEdit := containsString(supportedModes, "video_edit")
|
||||||
|
video, _ := item["video_url"].(map[string]any)
|
||||||
|
referType := stringFromAny(video["refer_type"])
|
||||||
|
isEditVideo := stringFromAny(item["role"]) == "video_base" || referType == "base"
|
||||||
|
isReferenceVideo := stringFromAny(item["role"]) == "video_feature" ||
|
||||||
|
stringFromAny(item["role"]) == "reference_video" ||
|
||||||
|
referType == "feature"
|
||||||
|
if isEditVideo {
|
||||||
|
return supportsEdit
|
||||||
|
}
|
||||||
|
if isReferenceVideo {
|
||||||
|
return supportsReference
|
||||||
|
}
|
||||||
|
return supportsReference || supportsEdit
|
||||||
|
}
|
||||||
|
|
||||||
|
func downgradeReferenceImageIfNeeded(params map[string]any, content []map[string]any, modelType string, context *paramProcessContext) error {
|
||||||
|
if !isVideoModelType(modelType) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if supportsReferenceImage(context.modelCapability, modelType) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
imageIndexes := make([]int, 0)
|
||||||
|
referenceIndexes := make([]int, 0)
|
||||||
|
hasVideoOrAudioReference := false
|
||||||
|
for index, item := range content {
|
||||||
|
if isVideoContent(item) || isAudioContent(item) {
|
||||||
|
hasVideoOrAudioReference = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isImageContent(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imageIndexes = append(imageIndexes, index)
|
||||||
|
role := stringFromAny(item["role"])
|
||||||
|
if role == "" || role == "reference_image" {
|
||||||
|
referenceIndexes = append(referenceIndexes, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(referenceIndexes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence := referenceImageDowngradeCapabilityEvidence(context.modelCapability, modelType)
|
||||||
|
if hasVideoOrAudioReference {
|
||||||
|
context.reject(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"content",
|
||||||
|
content,
|
||||||
|
"当前模型不支持多模态参考,不能将视频或音频参考降级为首尾帧,请移除视频/音频参考或选择支持多模态参考的模型。",
|
||||||
|
evidence.path,
|
||||||
|
evidence.value,
|
||||||
|
)
|
||||||
|
return context.err
|
||||||
|
}
|
||||||
|
if len(imageIndexes) > 2 {
|
||||||
|
context.reject(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"content",
|
||||||
|
content,
|
||||||
|
"当前模型不支持多参考图输入,最多只允许 2 张图片降级为首尾帧。",
|
||||||
|
evidence.path,
|
||||||
|
evidence.value,
|
||||||
|
)
|
||||||
|
return context.err
|
||||||
|
}
|
||||||
|
if len(imageIndexes) == 2 && !supportsFirstAndLastFrame(context.modelCapability, modelType) {
|
||||||
|
context.reject(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"content",
|
||||||
|
content,
|
||||||
|
"当前模型不支持首尾帧输入,不能将 2 张参考图降级为首尾帧。",
|
||||||
|
evidence.path,
|
||||||
|
evidence.value,
|
||||||
|
)
|
||||||
|
return context.err
|
||||||
|
}
|
||||||
|
if len(imageIndexes) == 1 && !supportsFirstFrame(context.modelCapability, modelType) {
|
||||||
|
context.reject(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"content",
|
||||||
|
content,
|
||||||
|
"当前模型不支持首帧输入,不能将参考图降级为首帧。",
|
||||||
|
evidence.path,
|
||||||
|
evidence.value,
|
||||||
|
)
|
||||||
|
return context.err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imageIndexes) == 1 {
|
||||||
|
adjustImageContentRole(content, imageIndexes[0], "first_frame", context, modelType, "模型不支持 reference_image,且只有 1 张图片,已降级为 first_frame。")
|
||||||
|
appendParamWarning(params, "reference_image is unsupported by the selected model and was downgraded to first_frame")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
firstIndex, lastIndex := firstLastFrameIndexes(content, imageIndexes)
|
||||||
|
adjustImageContentRole(content, firstIndex, "first_frame", context, modelType, "模型不支持 reference_image,2 张图片已降级为首尾帧的 first_frame。")
|
||||||
|
adjustImageContentRole(content, lastIndex, "last_frame", context, modelType, "模型不支持 reference_image,2 张图片已降级为首尾帧的 last_frame。")
|
||||||
|
appendParamWarning(params, "reference_image is unsupported by the selected model and was downgraded to first/last frame")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type capabilityEvidenceValue struct {
|
||||||
|
path string
|
||||||
|
value any
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceImageDowngradeCapabilityEvidence(modelCapability map[string]any, modelType string) capabilityEvidenceValue {
|
||||||
|
actualType, capability := firstVideoInputCapability(modelCapability, modelType)
|
||||||
|
if actualType == "" {
|
||||||
|
actualType = modelType
|
||||||
|
}
|
||||||
|
value := map[string]any{}
|
||||||
|
if capability != nil {
|
||||||
|
for _, key := range []string{
|
||||||
|
"input_reference_generate_single",
|
||||||
|
"input_reference_generate_multiple",
|
||||||
|
"max_images",
|
||||||
|
"input_first_frame",
|
||||||
|
"input_first_last_frame",
|
||||||
|
"max_images_for_last_frame",
|
||||||
|
} {
|
||||||
|
value[key] = cloneAny(capability[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return capabilityEvidenceValue{path: capabilityPath(actualType, ""), value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjustImageContentRole(content []map[string]any, index int, role string, context *paramProcessContext, modelType string, reason string) {
|
||||||
|
if index < 0 || index >= len(content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := content[index]
|
||||||
|
if stringFromAny(item["role"]) == role {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
before := cloneMap(item)
|
||||||
|
item["role"] = role
|
||||||
|
context.recordChange(
|
||||||
|
"ContentFilterProcessor",
|
||||||
|
"adjust",
|
||||||
|
fmt.Sprintf("content[%d].role", index),
|
||||||
|
before,
|
||||||
|
item,
|
||||||
|
reason,
|
||||||
|
capabilityPath(modelType, "input_reference_generate_single"),
|
||||||
|
referenceImageDowngradeCapabilityEvidence(context.modelCapability, modelType).value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLastFrameIndexes(content []map[string]any, imageIndexes []int) (int, int) {
|
||||||
|
firstIndex := -1
|
||||||
|
lastIndex := -1
|
||||||
|
for _, index := range imageIndexes {
|
||||||
|
switch stringFromAny(content[index]["role"]) {
|
||||||
|
case "first_frame":
|
||||||
|
if firstIndex == -1 {
|
||||||
|
firstIndex = index
|
||||||
|
}
|
||||||
|
case "last_frame":
|
||||||
|
if lastIndex == -1 {
|
||||||
|
lastIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstIndex == -1 && lastIndex == -1 {
|
||||||
|
return imageIndexes[0], imageIndexes[1]
|
||||||
|
}
|
||||||
|
if firstIndex == -1 {
|
||||||
|
for _, index := range imageIndexes {
|
||||||
|
if index != lastIndex {
|
||||||
|
firstIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastIndex == -1 {
|
||||||
|
for _, index := range imageIndexes {
|
||||||
|
if index != firstIndex {
|
||||||
|
lastIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstIndex == lastIndex {
|
||||||
|
return imageIndexes[0], imageIndexes[1]
|
||||||
|
}
|
||||||
|
return firstIndex, lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
type videoInputCapabilityValue struct {
|
||||||
|
modelType string
|
||||||
|
capability map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstVideoInputCapability(modelCapability map[string]any, modelType string) (string, map[string]any) {
|
||||||
|
for _, candidate := range videoInputCapabilityCandidates(modelCapability, modelType) {
|
||||||
|
return candidate.modelType, candidate.capability
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoInputCapabilityCandidates(modelCapability map[string]any, modelType string) []videoInputCapabilityValue {
|
||||||
|
keys := []string{modelType, "image_to_video", "video_first_last_frame"}
|
||||||
|
if modelType == "omni_video" || modelType == "omni" {
|
||||||
|
keys = append(keys, "omni_video", "omni")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]videoInputCapabilityValue, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" || seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if capability := capabilityForType(modelCapability, key); capability != nil {
|
||||||
|
out = append(out, videoInputCapabilityValue{modelType: key, capability: capability})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsReferenceImage(modelCapability map[string]any, modelType string) bool {
|
||||||
|
candidates := videoInputCapabilityCandidates(modelCapability, modelType)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
capability := candidate.capability
|
||||||
|
_, hasSingle := capability["input_reference_generate_single"]
|
||||||
|
_, hasMultiple := capability["input_reference_generate_multiple"]
|
||||||
|
if hasSingle || hasMultiple {
|
||||||
|
if boolFromAny(capability["input_reference_generate_single"]) || boolFromAny(capability["input_reference_generate_multiple"]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value, ok := numericField(capability, "max_images"); ok {
|
||||||
|
if value > 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsFirstFrame(modelCapability map[string]any, modelType string) bool {
|
||||||
|
for _, candidate := range videoInputCapabilityCandidates(modelCapability, modelType) {
|
||||||
|
capability := candidate.capability
|
||||||
|
if boolFromAny(capability["input_first_frame"]) ||
|
||||||
|
boolFromAny(capability["input_first_last_frame"]) ||
|
||||||
|
floatFromAny(capability["max_images_for_first_frame"]) > 0 ||
|
||||||
|
floatFromAny(capability["max_images_for_last_frame"]) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsFirstAndLastFrame(modelCapability map[string]any, modelType string) bool {
|
||||||
|
for _, candidate := range videoInputCapabilityCandidates(modelCapability, modelType) {
|
||||||
|
capability := candidate.capability
|
||||||
|
if boolFromAny(capability["input_first_last_frame"]) || floatFromAny(capability["max_images_for_last_frame"]) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoModeKey(params map[string]any) string {
|
||||||
|
content := contentItems(params["content"])
|
||||||
|
hasFirstFrame := false
|
||||||
|
hasLastFrame := false
|
||||||
|
for _, item := range content {
|
||||||
|
switch stringFromAny(item["role"]) {
|
||||||
|
case "first_frame":
|
||||||
|
hasFirstFrame = true
|
||||||
|
case "last_frame":
|
||||||
|
hasLastFrame = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case hasFirstFrame && hasLastFrame:
|
||||||
|
return "input_first_last_frame"
|
||||||
|
case hasFirstFrame:
|
||||||
|
return "input_first_frame"
|
||||||
|
case hasLastFrame:
|
||||||
|
return "input_last_frame"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncDurationSeconds(params map[string]any) {
|
||||||
|
if params["duration_seconds"] != nil {
|
||||||
|
params["duration_seconds"] = params["duration"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncVideoConvenienceFields(params map[string]any, content []map[string]any, context *paramProcessContext) {
|
||||||
|
hasVideo := false
|
||||||
|
hasAudio := false
|
||||||
|
for _, item := range content {
|
||||||
|
hasVideo = hasVideo || isVideoContent(item)
|
||||||
|
hasAudio = hasAudio || isAudioContent(item)
|
||||||
|
}
|
||||||
|
if !hasVideo {
|
||||||
|
path, value := omniCapabilityEvidence(context, "supported_modes")
|
||||||
|
deleteFieldsWithLog(params, context, "ContentFilterProcessor", []string{"video", "video_url", "videoUrl", "reference_video", "referenceVideo"}, "对应视频 content 已被模型能力过滤,移除视频参考快捷字段。", path, value)
|
||||||
|
}
|
||||||
|
if !hasAudio {
|
||||||
|
path, value := omniCapabilityEvidence(context, "input_audio")
|
||||||
|
deleteFieldsWithLog(params, context, "ContentFilterProcessor", []string{"audio_url", "audioUrl", "reference_audio", "referenceAudio"}, "对应音频 content 已被模型能力过滤,移除音频参考快捷字段。", path, mergeMetrics(map[string]any{"input_audio": value}, omniCapabilityBundle(context, "max_audios")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteFieldsWithLog(params map[string]any, context *paramProcessContext, processor string, keys []string, reason string, capabilityPath string, capabilityValue any) {
|
||||||
|
for _, key := range keys {
|
||||||
|
if before, ok := params[key]; ok {
|
||||||
|
delete(params, key)
|
||||||
|
context.recordChange(processor, "remove", key, before, nil, reason, capabilityPath, capabilityValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendParamWarning(params map[string]any, warning string) {
|
||||||
|
warnings, _ := params["_param_warnings"].([]any)
|
||||||
|
for _, item := range warnings {
|
||||||
|
if stringFromAny(item) == warning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params["_param_warnings"] = append(warnings, warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterContent(content []map[string]any, keep func(map[string]any) bool) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(content))
|
||||||
|
for _, item := range content {
|
||||||
|
if keep(item) {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@ -11,8 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type EstimateResult struct {
|
type EstimateResult struct {
|
||||||
Items []any `json:"items"`
|
Items []any `json:"items"`
|
||||||
Resolver string `json:"resolver"`
|
Resolver string `json:"resolver"`
|
||||||
|
TotalAmount float64 `json:"totalAmount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
|
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
|
||||||
@ -23,9 +25,12 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body
|
|||||||
}
|
}
|
||||||
candidate := candidates[0]
|
candidate := candidates[0]
|
||||||
body = preprocessRequest(kind, body, candidate)
|
body = preprocessRequest(kind, body, candidate)
|
||||||
|
items := s.estimatedBillings(ctx, user, kind, body, candidate)
|
||||||
return EstimateResult{
|
return EstimateResult{
|
||||||
Items: s.estimatedBillings(ctx, user, kind, body, candidate),
|
Items: items,
|
||||||
Resolver: "effective-pricing-v1",
|
Resolver: "effective-pricing-v1",
|
||||||
|
TotalAmount: totalBillingAmount(items),
|
||||||
|
Currency: billingCurrency(items),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,10 +65,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
|||||||
billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated),
|
billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
count := int(floatFromAny(body["n"]))
|
count := requestOutputCount(body)
|
||||||
if count <= 0 {
|
|
||||||
count = 1
|
|
||||||
}
|
|
||||||
resource := "image"
|
resource := "image"
|
||||||
unit := "image"
|
unit := "image"
|
||||||
baseKey := "imageBase"
|
baseKey := "imageBase"
|
||||||
@ -73,8 +75,24 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
|||||||
}
|
}
|
||||||
if kind == "videos.generations" {
|
if kind == "videos.generations" {
|
||||||
resource = "video"
|
resource = "video"
|
||||||
unit = "video"
|
unit = "5s_video"
|
||||||
baseKey = "videoBase"
|
baseKey = "videoBase"
|
||||||
|
duration := requestDurationSeconds(body)
|
||||||
|
durationUnits := math.Max(1, math.Ceil(duration/5))
|
||||||
|
amount := float64(count) *
|
||||||
|
durationUnits *
|
||||||
|
resourcePrice(config, resource, baseKey, "basePrice") *
|
||||||
|
resourceWeight(config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))) *
|
||||||
|
resourceWeight(config, resource, "audioWeights", boolWeightKey(boolishValue(body["audio"]))) *
|
||||||
|
resourceWeight(config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))) *
|
||||||
|
resourceWeight(config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body))) *
|
||||||
|
discount
|
||||||
|
return []any{billingLineWithDetails(candidate, resource, unit, count*int(durationUnits), roundPrice(amount), discount, simulated, map[string]any{
|
||||||
|
"count": count,
|
||||||
|
"durationSeconds": duration,
|
||||||
|
"durationUnit": "5s",
|
||||||
|
"durationUnitCount": durationUnits,
|
||||||
|
})}
|
||||||
}
|
}
|
||||||
amount := float64(count) * resourcePrice(config, resource, baseKey, "basePrice") * resourceWeight(config, resource, "qualityWeights", stringFromMap(body, "quality")) * resourceWeight(config, resource, "sizeWeights", stringFromMap(body, "size")) * resourceWeight(config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))) * discount
|
amount := float64(count) * resourcePrice(config, resource, baseKey, "basePrice") * resourceWeight(config, resource, "qualityWeights", stringFromMap(body, "quality")) * resourceWeight(config, resource, "sizeWeights", stringFromMap(body, "size")) * resourceWeight(config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))) * discount
|
||||||
return []any{billingLine(candidate, resource, unit, count, roundPrice(amount), discount, simulated)}
|
return []any{billingLine(candidate, resource, unit, count, roundPrice(amount), discount, simulated)}
|
||||||
@ -109,17 +127,23 @@ func effectiveDiscount(ctx context.Context, db *store.Store, user *auth.User, ca
|
|||||||
if discount <= 0 {
|
if discount <= 0 {
|
||||||
discount = 1
|
discount = 1
|
||||||
}
|
}
|
||||||
if group, err := db.ResolveUserGroupPolicy(ctx, user); err == nil {
|
if db != nil {
|
||||||
groupDiscount := floatFromAny(group.BillingDiscountPolicy["discountFactor"])
|
if group, err := db.ResolveUserGroupPolicy(ctx, user); err == nil {
|
||||||
if groupDiscount > 0 {
|
groupDiscount := floatFromAny(group.BillingDiscountPolicy["discountFactor"])
|
||||||
discount *= groupDiscount
|
if groupDiscount > 0 {
|
||||||
|
discount *= groupDiscount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return discount
|
return discount
|
||||||
}
|
}
|
||||||
|
|
||||||
func billingLine(candidate store.RuntimeModelCandidate, resourceType string, unit string, quantity any, amount float64, discount float64, simulated bool) map[string]any {
|
func billingLine(candidate store.RuntimeModelCandidate, resourceType string, unit string, quantity any, amount float64, discount float64, simulated bool) map[string]any {
|
||||||
return map[string]any{
|
return billingLineWithDetails(candidate, resourceType, unit, quantity, amount, discount, simulated, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func billingLineWithDetails(candidate store.RuntimeModelCandidate, resourceType string, unit string, quantity any, amount float64, discount float64, simulated bool, details map[string]any) map[string]any {
|
||||||
|
line := map[string]any{
|
||||||
"model": candidate.ModelName,
|
"model": candidate.ModelName,
|
||||||
"modelAlias": candidate.ModelAlias,
|
"modelAlias": candidate.ModelAlias,
|
||||||
"provider": candidate.Provider,
|
"provider": candidate.Provider,
|
||||||
@ -133,6 +157,10 @@ func billingLine(candidate store.RuntimeModelCandidate, resourceType string, uni
|
|||||||
"discountFactor": discount,
|
"discountFactor": discount,
|
||||||
"simulated": simulated,
|
"simulated": simulated,
|
||||||
}
|
}
|
||||||
|
for key, value := range details {
|
||||||
|
line[key] = value
|
||||||
|
}
|
||||||
|
return line
|
||||||
}
|
}
|
||||||
|
|
||||||
func price(config map[string]any, key string) float64 {
|
func price(config map[string]any, key string) float64 {
|
||||||
@ -177,7 +205,16 @@ func weighted(config map[string]any, key string, name string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resourceWeight(config map[string]any, resource string, key string, name string) float64 {
|
func resourceWeight(config map[string]any, resource string, key string, name string) float64 {
|
||||||
if value := weighted(config, key, name); value != 1 {
|
keys := weightKeyAliases(key)
|
||||||
|
names := weightValueAliases(key, name)
|
||||||
|
for _, candidateKey := range keys {
|
||||||
|
for _, candidateName := range names {
|
||||||
|
if value := weighted(config, candidateKey, candidateName); value != 1 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := dynamicWeight(config["dynamicWeight"], keys, names); value != 1 {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(name) == "" {
|
if strings.TrimSpace(name) == "" {
|
||||||
@ -187,19 +224,201 @@ func resourceWeight(config map[string]any, resource string, key string, name str
|
|||||||
if len(resourceConfig) == 0 && resource == "image_edit" {
|
if len(resourceConfig) == 0 && resource == "image_edit" {
|
||||||
resourceConfig, _ = config["image"].(map[string]any)
|
resourceConfig, _ = config["image"].(map[string]any)
|
||||||
}
|
}
|
||||||
if weights, ok := resourceConfig["dynamicWeight"].(map[string]any); ok {
|
if value := dynamicWeight(resourceConfig["dynamicWeight"], keys, names); value != 1 {
|
||||||
if value := floatFromAny(weights[name]); value > 0 {
|
return value
|
||||||
return value
|
}
|
||||||
|
for _, candidateKey := range keys {
|
||||||
|
if weights, ok := resourceConfig[candidateKey].(map[string]any); ok {
|
||||||
|
for _, candidateName := range names {
|
||||||
|
if value := floatFromAny(weights[candidateName]); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if weights, ok := resourceConfig[key].(map[string]any); ok {
|
return 1
|
||||||
if value := floatFromAny(weights[name]); value > 0 {
|
}
|
||||||
|
|
||||||
|
func dynamicWeight(value any, keys []string, names []string) float64 {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
weights, _ := value.(map[string]any)
|
||||||
|
if len(weights) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
if direct := floatFromAny(weights[name]); direct > 0 {
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if nested, ok := weights[key].(map[string]any); ok {
|
||||||
|
for _, name := range names {
|
||||||
|
if nestedValue := floatFromAny(nested[name]); nestedValue > 0 {
|
||||||
|
return nestedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightKeyAliases(key string) []string {
|
||||||
|
switch key {
|
||||||
|
case "qualityWeights":
|
||||||
|
return []string{"qualityWeights", "qualityFactors"}
|
||||||
|
case "resolutionWeights":
|
||||||
|
return []string{"resolutionWeights", "resolutionFactors"}
|
||||||
|
case "audioWeights":
|
||||||
|
return []string{"audioWeights", "audioFactors"}
|
||||||
|
case "referenceVideoWeights":
|
||||||
|
return []string{"referenceVideoWeights", "referenceVideoFactors"}
|
||||||
|
case "voiceSpecifiedWeights":
|
||||||
|
return []string{"voiceSpecifiedWeights", "voiceSpecifiedFactors"}
|
||||||
|
default:
|
||||||
|
return []string{key}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightValueAliases(key string, name string) []string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "audioWeights":
|
||||||
|
return []string{name, "audio-" + name}
|
||||||
|
case "referenceVideoWeights":
|
||||||
|
return []string{name, "reference-video-" + name}
|
||||||
|
case "voiceSpecifiedWeights":
|
||||||
|
return []string{name, "voice-specified-" + name}
|
||||||
|
default:
|
||||||
|
return []string{name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestOutputCount(body map[string]any) int {
|
||||||
|
for _, key := range []string{"n", "count", "batch_size", "batchSize"} {
|
||||||
|
if value := int(math.Ceil(floatFromAny(body[key]))); value > 0 {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestDurationSeconds(body map[string]any) float64 {
|
||||||
|
for _, key := range []string{"duration", "durationSeconds", "duration_seconds"} {
|
||||||
|
if value := floatFromAny(body[key]); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range body {
|
||||||
|
items, ok := value.([]any)
|
||||||
|
if !ok || len(items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total := 0.0
|
||||||
|
allDurationItems := true
|
||||||
|
for _, item := range items {
|
||||||
|
record, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
allDurationItems = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
duration := floatFromAny(record["duration"])
|
||||||
|
if duration <= 0 {
|
||||||
|
allDurationItems = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
total += duration
|
||||||
|
}
|
||||||
|
if allDurationItems && total > 0 {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHasReferenceVideo(body map[string]any) bool {
|
||||||
|
if hasNonEmptyArray(body["video_list"]) || hasNonEmptyArray(body["videoList"]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if firstNonEmptyStringValue(body, "video", "video_url", "videoUrl", "reference_video", "referenceVideo") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
content, _ := body["content"].([]any)
|
||||||
|
for _, item := range content {
|
||||||
|
record, _ := item.(map[string]any)
|
||||||
|
if len(record) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemType := strings.TrimSpace(stringFromAny(record["type"]))
|
||||||
|
role := strings.TrimSpace(stringFromAny(record["role"]))
|
||||||
|
if itemType == "video_url" || role == "video_feature" || role == "video_base" || role == "reference_video" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHasVoiceID(body map[string]any) bool {
|
||||||
|
return boolishValue(body["audio"]) && firstNonEmptyStringValue(body, "voice_id", "voiceId") != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolWeightKey(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolishValue(value any) bool {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case bool:
|
||||||
|
return typed
|
||||||
|
case string:
|
||||||
|
switch strings.ToLower(strings.TrimSpace(typed)) {
|
||||||
|
case "true", "1", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
return typed != 0
|
||||||
|
case int64:
|
||||||
|
return typed != 0
|
||||||
|
case float64:
|
||||||
|
return typed != 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNonEmptyArray(value any) bool {
|
||||||
|
items, ok := value.([]any)
|
||||||
|
return ok && len(items) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalBillingAmount(items []any) float64 {
|
||||||
|
total := 0.0
|
||||||
|
for _, raw := range items {
|
||||||
|
line, _ := raw.(map[string]any)
|
||||||
|
total += floatFromAny(line["amount"])
|
||||||
|
}
|
||||||
|
return roundPrice(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func billingCurrency(items []any) string {
|
||||||
|
for _, raw := range items {
|
||||||
|
line, _ := raw.(map[string]any)
|
||||||
|
if currency := stringFromAny(line["currency"]); currency != "" {
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "resource"
|
||||||
|
}
|
||||||
|
|
||||||
func firstNonEmptyString(values ...string) string {
|
func firstNonEmptyString(values ...string) string {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if strings.TrimSpace(value) != "" {
|
if strings.TrimSpace(value) != "" {
|
||||||
|
|||||||
132
apps/api/internal/runner/pricing_test.go
Normal file
132
apps/api/internal/runner/pricing_test.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImageBillingEstimateUsesCountResolutionAndQuality(t *testing.T) {
|
||||||
|
service := &Service{}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelName: "image-model",
|
||||||
|
BaseBillingConfig: map[string]any{
|
||||||
|
"image": map[string]any{
|
||||||
|
"basePrice": 10,
|
||||||
|
"dynamicWeight": map[string]any{
|
||||||
|
"resolutionFactors": map[string]any{"2K": 1.5},
|
||||||
|
"qualityFactors": map[string]any{"high": 1.5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := service.billings(context.Background(), nil, "images.generations", map[string]any{
|
||||||
|
"count": 2,
|
||||||
|
"quality": "high",
|
||||||
|
"resolution": "2K",
|
||||||
|
}, candidate, clients.Response{}, true)
|
||||||
|
|
||||||
|
line := firstBillingLine(t, items)
|
||||||
|
if got, want := floatFromAny(line["amount"]), 45.0; got != want {
|
||||||
|
t.Fatalf("image estimated amount = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := line["quantity"], 2; got != want {
|
||||||
|
t.Fatalf("image quantity = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T) {
|
||||||
|
service := &Service{}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelName: "video-model",
|
||||||
|
BaseBillingConfig: map[string]any{
|
||||||
|
"video": map[string]any{
|
||||||
|
"basePrice": 100,
|
||||||
|
"dynamicWeight": map[string]any{
|
||||||
|
"resolutionWeights": map[string]any{"1080p": 1.5},
|
||||||
|
"audioWeights": map[string]any{"true": 2},
|
||||||
|
"referenceVideoWeights": map[string]any{"true": 1.5},
|
||||||
|
"voiceSpecifiedWeights": map[string]any{"true": 1.2},
|
||||||
|
"unusedCompatibilityField": map[string]any{"true": 99},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := service.billings(context.Background(), nil, "videos.generations", map[string]any{
|
||||||
|
"audio": true,
|
||||||
|
"duration": 12,
|
||||||
|
"resolution": "1080p",
|
||||||
|
"voice_id": "voice-a",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/reference.mp4"}},
|
||||||
|
},
|
||||||
|
}, candidate, clients.Response{}, true)
|
||||||
|
|
||||||
|
line := firstBillingLine(t, items)
|
||||||
|
if got, want := floatFromAny(line["amount"]), 1620.0; got != want {
|
||||||
|
t.Fatalf("video estimated amount = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := floatFromAny(line["durationUnitCount"]), 3.0; got != want {
|
||||||
|
t.Fatalf("video duration units = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := line["quantity"], 3; got != want {
|
||||||
|
t.Fatalf("video quantity = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoBillingEstimateSupportsServerMainStyleDynamicKeys(t *testing.T) {
|
||||||
|
service := &Service{}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelName: "legacy-video-model",
|
||||||
|
BaseBillingConfig: map[string]any{
|
||||||
|
"videoBase": 100,
|
||||||
|
"video": map[string]any{
|
||||||
|
"dynamicWeight": map[string]any{
|
||||||
|
"720p": 1.25,
|
||||||
|
"audio-true": 2,
|
||||||
|
"reference-video-true": 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := service.billings(context.Background(), nil, "videos.generations", map[string]any{
|
||||||
|
"audio": "true",
|
||||||
|
"duration": 5,
|
||||||
|
"resolution": "720p",
|
||||||
|
"video_list": []any{map[string]any{"url": "https://example.com/reference.mp4"}},
|
||||||
|
}, candidate, clients.Response{}, true)
|
||||||
|
|
||||||
|
line := firstBillingLine(t, items)
|
||||||
|
if got, want := floatFromAny(line["amount"]), 375.0; got != want {
|
||||||
|
t.Fatalf("legacy video estimated amount = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoDurationEstimateSumsMultiShotDurations(t *testing.T) {
|
||||||
|
duration := requestDurationSeconds(map[string]any{
|
||||||
|
"multi_prompt": []any{
|
||||||
|
map[string]any{"prompt": "shot 1", "duration": 3},
|
||||||
|
map[string]any{"prompt": "shot 2", "duration": 7},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if duration != 10 {
|
||||||
|
t.Fatalf("multi-shot duration = %v, want 10", duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstBillingLine(t *testing.T, items []any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items length = %d, want 1: %+v", len(items), items)
|
||||||
|
}
|
||||||
|
line, ok := items[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("item type = %T, want map[string]any", items[0])
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
@ -86,7 +86,7 @@ func taskMetrics(task store.GatewayTask, user *auth.User, body map[string]any, c
|
|||||||
copyIfPresent(metrics, body, "style")
|
copyIfPresent(metrics, body, "style")
|
||||||
case "videos.generations":
|
case "videos.generations":
|
||||||
metrics["hasReferenceImage"] = imageInputCount(body) > 0
|
metrics["hasReferenceImage"] = imageInputCount(body) > 0
|
||||||
metrics["hasReferenceVideo"] = hasAnyString(body, "video", "video_url", "videoUrl", "reference_video", "referenceVideo")
|
metrics["hasReferenceVideo"] = hasAnyString(body, "video", "video_url", "videoUrl", "reference_video", "referenceVideo") || hasVideoContent(body)
|
||||||
copyIfPresent(metrics, body, "duration")
|
copyIfPresent(metrics, body, "duration")
|
||||||
copyIfPresent(metrics, body, "resolution")
|
copyIfPresent(metrics, body, "resolution")
|
||||||
copyIfPresent(metrics, body, "size")
|
copyIfPresent(metrics, body, "size")
|
||||||
@ -303,9 +303,23 @@ func imageInputCount(body map[string]any) int {
|
|||||||
count += len(values)
|
count += len(values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, item := range contentItems(body["content"]) {
|
||||||
|
if isImageContent(item) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasVideoContent(body map[string]any) bool {
|
||||||
|
for _, item := range contentItems(body["content"]) {
|
||||||
|
if isVideoContent(item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func hasAnyString(body map[string]any, keys ...string) bool {
|
func hasAnyString(body map[string]any, keys ...string) bool {
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if stringFromMap(body, key) != "" {
|
if stringFromMap(body, key) != "" {
|
||||||
|
|||||||
@ -104,6 +104,17 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
firstCandidateBody = preprocessing.Body
|
firstCandidateBody = preprocessing.Body
|
||||||
firstPreprocessing = preprocessing.Log
|
firstPreprocessing = preprocessing.Log
|
||||||
normalizedModelType = candidates[0].ModelType
|
normalizedModelType = candidates[0].ModelType
|
||||||
|
if preprocessing.Err != nil {
|
||||||
|
clientErr := parameterPreprocessClientError(preprocessing.Err)
|
||||||
|
if logErr := s.recordTaskParameterPreprocessing(ctx, task.ID, "", 0, candidates[0], firstPreprocessing); logErr != nil {
|
||||||
|
return Result{}, logErr
|
||||||
|
}
|
||||||
|
failed, finishErr := s.failTask(ctx, task.ID, clients.ErrorCode(clientErr), clientErr.Error(), task.RunMode == "simulation", clientErr, parameterPreprocessingMetrics(firstPreprocessing))
|
||||||
|
if finishErr != nil {
|
||||||
|
return Result{}, finishErr
|
||||||
|
}
|
||||||
|
return Result{Task: failed, Output: failed.Result}, clientErr
|
||||||
|
}
|
||||||
if err := s.store.MarkTaskRunning(ctx, task.ID, candidates[0].ModelType, firstCandidateBody); err != nil {
|
if err := s.store.MarkTaskRunning(ctx, task.ID, candidates[0].ModelType, firstCandidateBody); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
@ -149,6 +160,10 @@ candidatesLoop:
|
|||||||
preprocessing := preprocessRequestWithLog(task.Kind, body, candidate)
|
preprocessing := preprocessRequestWithLog(task.Kind, body, candidate)
|
||||||
preprocessingLog := preprocessing.Log
|
preprocessingLog := preprocessing.Log
|
||||||
lastPreprocessing = &preprocessingLog
|
lastPreprocessing = &preprocessingLog
|
||||||
|
if preprocessing.Err != nil {
|
||||||
|
lastErr = parameterPreprocessClientError(preprocessing.Err)
|
||||||
|
break candidatesLoop
|
||||||
|
}
|
||||||
candidateBody := preprocessing.Body
|
candidateBody := preprocessing.Body
|
||||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta)
|
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -481,7 +496,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
|||||||
s.applyCandidateFailurePolicies(ctx, task.ID, candidate, err, simulated)
|
s.applyCandidateFailurePolicies(ctx, task.ID, candidate, err, simulated)
|
||||||
return clients.Response{}, err
|
return clients.Response{}, err
|
||||||
}
|
}
|
||||||
uploadedResult, err := s.uploadGeneratedAssets(ctx, response.Result)
|
uploadedResult, err := s.uploadGeneratedAssets(ctx, task.ID, task.Kind, response.Result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
metrics := mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), parameterPreprocessingMetrics(preprocessing), map[string]any{
|
metrics := mergeMetrics(taskMetrics(task, user, body, candidate, response, simulated), parameterPreprocessingMetrics(preprocessing), map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@ -531,6 +546,9 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID string, attemptID string, attemptNo int, candidate store.RuntimeModelCandidate, log parameterPreprocessingLog) error {
|
func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID string, attemptID string, attemptNo int, candidate store.RuntimeModelCandidate, log parameterPreprocessingLog) error {
|
||||||
|
if skipTaskParameterPreprocessingLog(log.ModelType) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
_, err := s.store.CreateTaskParamPreprocessingLog(ctx, store.CreateTaskParamPreprocessingLogInput{
|
_, err := s.store.CreateTaskParamPreprocessingLog(ctx, store.CreateTaskParamPreprocessingLogInput{
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
AttemptID: attemptID,
|
AttemptID: attemptID,
|
||||||
@ -549,6 +567,15 @@ func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID s
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func skipTaskParameterPreprocessingLog(modelType string) bool {
|
||||||
|
switch strings.TrimSpace(modelType) {
|
||||||
|
case "text_generate", "chat", "responses", "text":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) clientFor(candidate store.RuntimeModelCandidate, simulated bool) clients.Client {
|
func (s *Service) clientFor(candidate store.RuntimeModelCandidate, simulated bool) clients.Client {
|
||||||
if simulated {
|
if simulated {
|
||||||
return s.clients["simulation"]
|
return s.clients["simulation"]
|
||||||
@ -687,7 +714,7 @@ func requestedModelTypeFromBody(body map[string]any) string {
|
|||||||
|
|
||||||
func isKnownModelType(value string) bool {
|
func isKnownModelType(value string) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case "text_generate", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "omni_video", "omni":
|
case "text_generate", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@ -706,6 +733,11 @@ func videoRequestHasReferenceImage(body map[string]any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, item := range contentItems(body["content"]) {
|
||||||
|
if isImageContent(item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -851,3 +883,15 @@ func validateRequest(kind string, body map[string]any) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parameterPreprocessClientError(err error) *clients.ClientError {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &clients.ClientError{
|
||||||
|
Code: "invalid_parameter",
|
||||||
|
Message: err.Error(),
|
||||||
|
StatusCode: 400,
|
||||||
|
Retryable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
279
apps/api/internal/runner/upload_test.go
Normal file
279
apps/api/internal/runner/upload_test.go
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedAssetDecisionSkipsURLResultAndStripsInlinePayload(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"b64_json": base64.StdEncoding.EncodeToString([]byte("inline image")),
|
||||||
|
"url": "https://cdn.example.com/generated.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := generatedAssetDecisionForItem("images.generations", item, defaultGeneratedAssetUploadPolicy())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if decision.Inline != nil {
|
||||||
|
t.Fatalf("URL media should not be uploaded by the default policy")
|
||||||
|
}
|
||||||
|
if !containsString(decision.StripKeys, "b64_json") {
|
||||||
|
t.Fatalf("inline payload should be stripped when URL is already available: %+v", decision.StripKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetDecisionUploadsInlineImageBase64(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"b64_json": base64.StdEncoding.EncodeToString([]byte("inline image")),
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := generatedAssetDecisionForItem("images.generations", item, defaultGeneratedAssetUploadPolicy())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if decision.Inline == nil {
|
||||||
|
t.Fatalf("expected inline image to be uploaded")
|
||||||
|
}
|
||||||
|
if decision.Inline.Kind != "image" || decision.Inline.ContentType != "image/jpeg" {
|
||||||
|
t.Fatalf("unexpected inline image metadata: %+v", decision.Inline)
|
||||||
|
}
|
||||||
|
if !containsString(decision.StripKeys, "b64_json") {
|
||||||
|
t.Fatalf("uploaded inline payload should be stripped: %+v", decision.StripKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetDecisionUploadsInlineVideoBuffer(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"type": "video",
|
||||||
|
"video_buffer": []any{float64(0), float64(1), float64(2), float64(3)},
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := generatedAssetDecisionForItem("videos.generations", item, defaultGeneratedAssetUploadPolicy())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if decision.Inline == nil {
|
||||||
|
t.Fatalf("expected inline video buffer to be uploaded")
|
||||||
|
}
|
||||||
|
if decision.Inline.Kind != "video" || decision.Inline.ContentType != "video/mp4" {
|
||||||
|
t.Fatalf("unexpected inline video metadata: %+v", decision.Inline)
|
||||||
|
}
|
||||||
|
if !containsString(decision.StripKeys, "video_buffer") {
|
||||||
|
t.Fatalf("uploaded video buffer should be stripped: %+v", decision.StripKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetDecisionUploadsDataURL(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"url": "data:image/webp;base64," + base64.StdEncoding.EncodeToString([]byte("inline webp")),
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := generatedAssetDecisionForItem("images.generations", item, defaultGeneratedAssetUploadPolicy())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if decision.Inline == nil {
|
||||||
|
t.Fatalf("expected data URL to be uploaded")
|
||||||
|
}
|
||||||
|
if decision.Inline.SourceKey != "url" || decision.Inline.ContentType != "image/webp" {
|
||||||
|
t.Fatalf("unexpected data URL metadata: %+v", decision.Inline)
|
||||||
|
}
|
||||||
|
if !containsString(decision.StripKeys, "url") {
|
||||||
|
t.Fatalf("uploaded data URL field should be stripped: %+v", decision.StripKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetDecisionUploadsURLWhenPolicyUploadAll(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"type": "video",
|
||||||
|
"video_url": "https://cdn.example.com/generated.mp4",
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := generatedAssetDecisionForItem("videos.generations", item, generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if decision.URL == nil {
|
||||||
|
t.Fatalf("expected URL media to be uploaded")
|
||||||
|
}
|
||||||
|
if decision.URL.Kind != "video" || decision.URL.SourceKey != "video_url" {
|
||||||
|
t.Fatalf("unexpected URL media metadata: %+v", decision.URL)
|
||||||
|
}
|
||||||
|
if !containsString(decision.StripKeys, "video_url") {
|
||||||
|
t.Fatalf("uploaded URL field should be stripped: %+v", decision.StripKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetDecisionStoresInlineLocallyWhenPolicyUploadNone(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"b64_json": base64.StdEncoding.EncodeToString([]byte("inline image")),
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := generatedAssetDecisionForItem("images.generations", item, generatedAssetUploadPolicyFromName(store.FileStorageResultUploadPolicyUploadNone))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if decision.Inline == nil || decision.URL != nil {
|
||||||
|
t.Fatalf("upload_none should still turn inline payloads into static URLs: %+v", decision)
|
||||||
|
}
|
||||||
|
if !containsString(decision.StripKeys, "b64_json") {
|
||||||
|
t.Fatalf("inline payload should be stripped before persistence: %+v", decision.StripKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetUploadPolicyFromName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policyName string
|
||||||
|
want generatedAssetUploadPolicy
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
policyName: store.FileStorageResultUploadPolicyDefault,
|
||||||
|
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload all",
|
||||||
|
policyName: store.FileStorageResultUploadPolicyUploadAll,
|
||||||
|
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true, StoreInlineMediaLocally: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload none",
|
||||||
|
policyName: store.FileStorageResultUploadPolicyUploadNone,
|
||||||
|
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := generatedAssetUploadPolicyFromName(tt.policyName)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("unexpected policy: got %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvedGeneratedAssetContentTypePrefersDetectedMedia(t *testing.T) {
|
||||||
|
pngPayload := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0}
|
||||||
|
|
||||||
|
contentType := resolvedGeneratedAssetContentType("image/jpeg", "image", pngPayload)
|
||||||
|
if contentType != "image/png" {
|
||||||
|
t.Fatalf("expected detected PNG content type, got %s", contentType)
|
||||||
|
}
|
||||||
|
if extension := fileExtensionForContentType(contentType, "image"); extension != ".png" {
|
||||||
|
t.Fatalf("expected PNG extension, got %s", extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvedGeneratedAssetContentTypeKeepsDeclaredMediaWhenDetectionIsGeneric(t *testing.T) {
|
||||||
|
contentType := resolvedGeneratedAssetContentType("image/webp", "image", []byte("not enough media bytes"))
|
||||||
|
if contentType != "image/webp" {
|
||||||
|
t.Fatalf("expected declared webp content type, got %s", contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetFileNameIsUniqueAndTyped(t *testing.T) {
|
||||||
|
first := generatedAssetFileName("663e19cd4fa9d8078385c7c9", 0, "image/png", "image")
|
||||||
|
second := generatedAssetFileName("663e19cd4fa9d8078385c7c9", 0, "image/png", "image")
|
||||||
|
if first == second {
|
||||||
|
t.Fatalf("expected generated file names to be unique, both were %s", first)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(first, "gateway-result-663e19cd4fa9d8078385c7c9-01-") || !strings.HasSuffix(first, ".png") {
|
||||||
|
t.Fatalf("unexpected generated file name: %s", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadGeneratedAssetStoresLocalWhenNoChannels(t *testing.T) {
|
||||||
|
storageDir := t.TempDir()
|
||||||
|
service := &Service{cfg: config.Config{LocalGeneratedStorageDir: storageDir}}
|
||||||
|
payload := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0}
|
||||||
|
asset := &generatedInlineAsset{
|
||||||
|
Bytes: payload,
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
Kind: "image",
|
||||||
|
SourceKey: "b64_json",
|
||||||
|
}
|
||||||
|
|
||||||
|
upload, contentType, kind, strategy, err := service.uploadGeneratedAsset(context.Background(), "task-123", asset, 0, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if contentType != "image/png" || kind != "image" || strategy != "local_static_inline_media" {
|
||||||
|
t.Fatalf("unexpected local upload metadata: contentType=%s kind=%s strategy=%s", contentType, kind, strategy)
|
||||||
|
}
|
||||||
|
urlValue := stringFromAny(upload["url"])
|
||||||
|
if !strings.HasPrefix(urlValue, "/static/generated/gateway-result-task-123-01-") || !strings.HasSuffix(urlValue, ".png") {
|
||||||
|
t.Fatalf("unexpected local static URL: %s", urlValue)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(storageDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read local static dir: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".png") {
|
||||||
|
t.Fatalf("expected one PNG file in local static dir, got %+v", entries)
|
||||||
|
}
|
||||||
|
stored, err := os.ReadFile(filepath.Join(storageDir, entries[0].Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read local static file: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(stored, payload) {
|
||||||
|
t.Fatalf("stored payload does not match source payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileStoresLocalWhenNoChannels(t *testing.T) {
|
||||||
|
storageDir := t.TempDir()
|
||||||
|
service := &Service{cfg: config.Config{
|
||||||
|
LocalUploadedStorageDir: storageDir,
|
||||||
|
ServerMainBaseURL: "http://127.0.0.1:1",
|
||||||
|
ServerMainInternalToken: "change-me",
|
||||||
|
}}
|
||||||
|
payload := []byte("%PDF-1.4")
|
||||||
|
|
||||||
|
upload, err := service.UploadFile(context.Background(), FileUploadPayload{
|
||||||
|
Bytes: payload,
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
FileName: "用户文件.png",
|
||||||
|
Source: "playground",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
urlValue := stringFromAny(upload["url"])
|
||||||
|
if !strings.HasPrefix(urlValue, "/static/uploaded/") || !strings.HasSuffix(urlValue, ".pdf") {
|
||||||
|
t.Fatalf("unexpected uploaded local static URL: %s", urlValue)
|
||||||
|
}
|
||||||
|
storageChannel, _ := upload["storageChannel"].(map[string]any)
|
||||||
|
if stringFromAny(storageChannel["provider"]) != "local_static" {
|
||||||
|
t.Fatalf("expected local static provider metadata, got %+v", upload["storageChannel"])
|
||||||
|
}
|
||||||
|
assetStorage, _ := upload["assetStorage"].(map[string]any)
|
||||||
|
if stringFromAny(assetStorage["strategy"]) != "local_static_upload" || stringFromAny(assetStorage["scene"]) != store.FileStorageSceneUpload {
|
||||||
|
t.Fatalf("unexpected upload asset storage metadata: %+v", assetStorage)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(storageDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read uploaded static dir: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".pdf") {
|
||||||
|
t.Fatalf("expected one PDF file in uploaded static dir, got %+v", entries)
|
||||||
|
}
|
||||||
|
stored, err := os.ReadFile(filepath.Join(storageDir, entries[0].Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read uploaded static file: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(stored, payload) {
|
||||||
|
t.Fatalf("stored uploaded payload does not match source payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
499
apps/api/internal/store/file_storage_channels.go
Normal file
499
apps/api/internal/store/file_storage_channels.go
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultServerMainUploadURL = "http://127.0.0.1:3001/v1/files/upload"
|
||||||
|
|
||||||
|
const (
|
||||||
|
FileStorageSceneUpload = "upload"
|
||||||
|
FileStorageSceneImageResult = "image_result"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FileStorageResultUploadPolicyDefault = "default"
|
||||||
|
FileStorageResultUploadPolicyUploadAll = "upload_all"
|
||||||
|
FileStorageResultUploadPolicyUploadNone = "upload_none"
|
||||||
|
)
|
||||||
|
|
||||||
|
const SystemSettingFileStorage = "file_storage"
|
||||||
|
|
||||||
|
const fileStorageChannelColumns = `
|
||||||
|
id::text, channel_key, name, provider, COALESCE(upload_url, ''), credentials,
|
||||||
|
config, retry_policy, priority, status, COALESCE(last_error, ''),
|
||||||
|
COALESCE(last_failed_at::text, ''), COALESCE(last_succeeded_at::text, ''),
|
||||||
|
created_at, updated_at`
|
||||||
|
|
||||||
|
type FileStorageChannel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ChannelKey string `json:"channelKey"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
UploadURL string `json:"uploadUrl,omitempty"`
|
||||||
|
APIKey string `json:"-"`
|
||||||
|
CredentialsPreview map[string]any `json:"credentialsPreview,omitempty"`
|
||||||
|
Scenes []string `json:"scenes,omitempty"`
|
||||||
|
Config map[string]any `json:"config,omitempty"`
|
||||||
|
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
LastFailedAt string `json:"lastFailedAt,omitempty"`
|
||||||
|
LastSucceededAt string `json:"lastSucceededAt,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileStorageChannelInput struct {
|
||||||
|
ChannelKey string `json:"channelKey"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
UploadURL string `json:"uploadUrl"`
|
||||||
|
APIKey *string `json:"apiKey"`
|
||||||
|
Scenes []string `json:"scenes"`
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
|
RetryPolicy map[string]any `json:"retryPolicy"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileStorageSettings struct {
|
||||||
|
ResultUploadPolicy string `json:"resultUploadPolicy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileStorageSettingsInput struct {
|
||||||
|
ResultUploadPolicy string `json:"resultUploadPolicy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileStorageChannelScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListFileStorageChannels(ctx context.Context) ([]FileStorageChannel, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+fileStorageChannelColumns+`
|
||||||
|
FROM file_storage_channels
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY priority ASC, created_at ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := make([]FileStorageChannel, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanFileStorageChannel(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListEnabledFileStorageChannels(ctx context.Context) ([]FileStorageChannel, error) {
|
||||||
|
return s.listEnabledFileStorageChannels(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListEnabledFileStorageChannelsForScene(ctx context.Context, scene string) ([]FileStorageChannel, error) {
|
||||||
|
return s.listEnabledFileStorageChannels(ctx, normalizeFileStorageScene(scene))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listEnabledFileStorageChannels(ctx context.Context, scene string) ([]FileStorageChannel, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+fileStorageChannelColumns+`
|
||||||
|
FROM file_storage_channels
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND status = 'enabled'
|
||||||
|
AND (
|
||||||
|
$1 = ''
|
||||||
|
OR NOT (config ? 'scenes')
|
||||||
|
OR jsonb_typeof(config->'scenes') <> 'array'
|
||||||
|
OR (config->'scenes') ? $1
|
||||||
|
)
|
||||||
|
ORDER BY priority ASC, created_at ASC`, scene)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := make([]FileStorageChannel, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanFileStorageChannel(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetFileStorageChannel(ctx context.Context, id string) (FileStorageChannel, error) {
|
||||||
|
return scanFileStorageChannel(s.pool.QueryRow(ctx, `
|
||||||
|
SELECT `+fileStorageChannelColumns+`
|
||||||
|
FROM file_storage_channels
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
AND deleted_at IS NULL`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFileStorageChannel(ctx context.Context, input FileStorageChannelInput) (FileStorageChannel, error) {
|
||||||
|
input = normalizeFileStorageChannelInput(input)
|
||||||
|
credentials, _ := json.Marshal(credentialsFromFileStorageInput(input))
|
||||||
|
config, _ := json.Marshal(configFromFileStorageInput(input))
|
||||||
|
retryPolicy, _ := json.Marshal(defaultFileStorageRetryPolicyIfEmpty(input.RetryPolicy))
|
||||||
|
|
||||||
|
return scanFileStorageChannel(s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO file_storage_channels (
|
||||||
|
channel_key, name, provider, upload_url, credentials, config, retry_policy, priority, status
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9)
|
||||||
|
RETURNING `+fileStorageChannelColumns,
|
||||||
|
input.ChannelKey,
|
||||||
|
input.Name,
|
||||||
|
input.Provider,
|
||||||
|
input.UploadURL,
|
||||||
|
credentials,
|
||||||
|
config,
|
||||||
|
retryPolicy,
|
||||||
|
input.Priority,
|
||||||
|
input.Status,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateFileStorageChannel(ctx context.Context, id string, input FileStorageChannelInput) (FileStorageChannel, error) {
|
||||||
|
input = normalizeFileStorageChannelInput(input)
|
||||||
|
replaceCredentials := input.APIKey != nil
|
||||||
|
credentials, _ := json.Marshal(credentialsFromFileStorageInput(input))
|
||||||
|
config, _ := json.Marshal(configFromFileStorageInput(input))
|
||||||
|
retryPolicy, _ := json.Marshal(defaultFileStorageRetryPolicyIfEmpty(input.RetryPolicy))
|
||||||
|
|
||||||
|
return scanFileStorageChannel(s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE file_storage_channels
|
||||||
|
SET channel_key = $2,
|
||||||
|
name = $3,
|
||||||
|
provider = $4,
|
||||||
|
upload_url = NULLIF($5, ''),
|
||||||
|
credentials = CASE WHEN $6::boolean THEN $7 ELSE credentials END,
|
||||||
|
config = $8,
|
||||||
|
retry_policy = $9,
|
||||||
|
priority = $10,
|
||||||
|
status = $11,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
RETURNING `+fileStorageChannelColumns,
|
||||||
|
id,
|
||||||
|
input.ChannelKey,
|
||||||
|
input.Name,
|
||||||
|
input.Provider,
|
||||||
|
input.UploadURL,
|
||||||
|
replaceCredentials,
|
||||||
|
credentials,
|
||||||
|
config,
|
||||||
|
retryPolicy,
|
||||||
|
input.Priority,
|
||||||
|
input.Status,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteFileStorageChannel(ctx context.Context, id string) error {
|
||||||
|
result, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE file_storage_channels
|
||||||
|
SET deleted_at = now(),
|
||||||
|
status = 'disabled',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
AND deleted_at IS NULL`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkFileStorageChannelFailure(ctx context.Context, id string, message string) error {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE file_storage_channels
|
||||||
|
SET last_error = NULLIF($2, ''),
|
||||||
|
last_failed_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
AND deleted_at IS NULL`, id, strings.TrimSpace(message))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkFileStorageChannelSuccess(ctx context.Context, id string) error {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE file_storage_channels
|
||||||
|
SET last_error = NULL,
|
||||||
|
last_succeeded_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
AND deleted_at IS NULL`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFileStorageChannel(scanner fileStorageChannelScanner) (FileStorageChannel, error) {
|
||||||
|
var item FileStorageChannel
|
||||||
|
var credentials []byte
|
||||||
|
var config []byte
|
||||||
|
var retryPolicy []byte
|
||||||
|
if err := scanner.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.ChannelKey,
|
||||||
|
&item.Name,
|
||||||
|
&item.Provider,
|
||||||
|
&item.UploadURL,
|
||||||
|
&credentials,
|
||||||
|
&config,
|
||||||
|
&retryPolicy,
|
||||||
|
&item.Priority,
|
||||||
|
&item.Status,
|
||||||
|
&item.LastError,
|
||||||
|
&item.LastFailedAt,
|
||||||
|
&item.LastSucceededAt,
|
||||||
|
&item.CreatedAt,
|
||||||
|
&item.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return FileStorageChannel{}, err
|
||||||
|
}
|
||||||
|
credentialObject := decodeObject(credentials)
|
||||||
|
item.APIKey = stringFromObject(credentialObject, "apiKey")
|
||||||
|
item.CredentialsPreview = maskCredentialsPreview(credentials)
|
||||||
|
configObject := decodeObject(config)
|
||||||
|
item.Scenes = fileStorageScenesFromConfig(configObject)
|
||||||
|
item.Config = fileStorageConfigWithoutManagedFields(configObject)
|
||||||
|
item.RetryPolicy = decodeObject(retryPolicy)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFileStorageChannelInput(input FileStorageChannelInput) FileStorageChannelInput {
|
||||||
|
input.ChannelKey = strings.TrimSpace(input.ChannelKey)
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
input.Provider = strings.ToLower(strings.TrimSpace(input.Provider))
|
||||||
|
input.UploadURL = strings.TrimSpace(input.UploadURL)
|
||||||
|
if input.APIKey != nil {
|
||||||
|
apiKey := strings.TrimSpace(*input.APIKey)
|
||||||
|
input.APIKey = &apiKey
|
||||||
|
}
|
||||||
|
input.Scenes = normalizeFileStorageScenes(input.Scenes)
|
||||||
|
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
|
||||||
|
if input.Provider == "" {
|
||||||
|
input.Provider = "server_main_openapi"
|
||||||
|
}
|
||||||
|
if input.Provider == "server_main_openapi" && input.UploadURL == "" {
|
||||||
|
input.UploadURL = defaultServerMainUploadURL
|
||||||
|
}
|
||||||
|
if input.Status == "" {
|
||||||
|
input.Status = "disabled"
|
||||||
|
}
|
||||||
|
if input.Priority <= 0 {
|
||||||
|
input.Priority = 100
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialsFromFileStorageInput(input FileStorageChannelInput) map[string]any {
|
||||||
|
apiKey := fileStorageInputAPIKey(input)
|
||||||
|
if apiKey == "" {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{"apiKey": apiKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileStorageInputAPIKey(input FileStorageChannelInput) string {
|
||||||
|
if input.APIKey == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*input.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFromFileStorageInput(input FileStorageChannelInput) map[string]any {
|
||||||
|
config := map[string]any{}
|
||||||
|
for key, value := range emptyObjectIfNil(input.Config) {
|
||||||
|
config[key] = value
|
||||||
|
}
|
||||||
|
config["scenes"] = normalizeFileStorageScenes(input.Scenes)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileStorageConfigWithoutManagedFields(config map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for key, value := range config {
|
||||||
|
if key == "scenes" || key == "resultUploadPolicy" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultFileStorageSettings() FileStorageSettings {
|
||||||
|
return FileStorageSettings{ResultUploadPolicy: FileStorageResultUploadPolicyDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetFileStorageSettings(ctx context.Context) (FileStorageSettings, error) {
|
||||||
|
var value []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT value
|
||||||
|
FROM system_settings
|
||||||
|
WHERE setting_key = $1`, SystemSettingFileStorage).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return DefaultFileStorageSettings(), nil
|
||||||
|
}
|
||||||
|
return FileStorageSettings{}, err
|
||||||
|
}
|
||||||
|
return fileStorageSettingsFromValue(decodeObject(value)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateFileStorageSettings(ctx context.Context, input FileStorageSettingsInput) (FileStorageSettings, error) {
|
||||||
|
settings := FileStorageSettings{ResultUploadPolicy: NormalizeFileStorageResultUploadPolicy(input.ResultUploadPolicy)}
|
||||||
|
value, _ := json.Marshal(settings)
|
||||||
|
var saved []byte
|
||||||
|
err := s.upsertFileStorageSettings(ctx, value, &saved)
|
||||||
|
if err != nil && IsUndefinedDatabaseObject(err) {
|
||||||
|
if ensureErr := s.ensureSystemSettingsTable(ctx); ensureErr != nil {
|
||||||
|
return FileStorageSettings{}, ensureErr
|
||||||
|
}
|
||||||
|
err = s.upsertFileStorageSettings(ctx, value, &saved)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return FileStorageSettings{}, err
|
||||||
|
}
|
||||||
|
return fileStorageSettingsFromValue(decodeObject(saved)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) upsertFileStorageSettings(ctx context.Context, value []byte, saved *[]byte) error {
|
||||||
|
return s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO system_settings (setting_key, value)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (setting_key)
|
||||||
|
DO UPDATE SET value = EXCLUDED.value, updated_at = now()
|
||||||
|
RETURNING value`, SystemSettingFileStorage, value).Scan(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ensureSystemSettingsTable(ctx context.Context) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
setting_key text PRIMARY KEY,
|
||||||
|
value jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileStorageSettingsFromValue(value map[string]any) FileStorageSettings {
|
||||||
|
settings := DefaultFileStorageSettings()
|
||||||
|
if value == nil {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
settings.ResultUploadPolicy = NormalizeFileStorageResultUploadPolicy(stringFromAny(value["resultUploadPolicy"]))
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeFileStorageResultUploadPolicy(policy string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(policy))
|
||||||
|
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||||||
|
switch normalized {
|
||||||
|
case "", "default", "non_link_only", "inline_only", "nonlink_only", "non_link":
|
||||||
|
return FileStorageResultUploadPolicyDefault
|
||||||
|
case "upload_all", "all", "always", "all_upload":
|
||||||
|
return FileStorageResultUploadPolicyUploadAll
|
||||||
|
case "upload_none", "none", "never", "disabled", "no_upload", "skip", "skip_all":
|
||||||
|
return FileStorageResultUploadPolicyUploadNone
|
||||||
|
default:
|
||||||
|
return FileStorageResultUploadPolicyDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileStorageScenesFromConfig(config map[string]any) []string {
|
||||||
|
if config == nil {
|
||||||
|
return defaultFileStorageScenes()
|
||||||
|
}
|
||||||
|
raw, ok := config["scenes"]
|
||||||
|
if !ok {
|
||||||
|
return defaultFileStorageScenes()
|
||||||
|
}
|
||||||
|
items, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return defaultFileStorageScenes()
|
||||||
|
}
|
||||||
|
scenes := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if value, ok := item.(string); ok {
|
||||||
|
scenes = append(scenes, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeFileStorageScenes(scenes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFileStorageScenes(scenes []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(scenes))
|
||||||
|
for _, item := range scenes {
|
||||||
|
scene := normalizeFileStorageScene(item)
|
||||||
|
if scene == "" || seen[scene] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[scene] = true
|
||||||
|
out = append(out, scene)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return defaultFileStorageScenes()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFileStorageScene(scene string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(scene))
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFileStorageScenes() []string {
|
||||||
|
return []string{FileStorageSceneUpload, FileStorageSceneImageResult}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFileStorageRetryPolicyIfEmpty(policy map[string]any) map[string]any {
|
||||||
|
if len(policy) > 0 {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"maxRetries": 3,
|
||||||
|
"backoffSeconds": []any{60, 120, 180},
|
||||||
|
"strategy": "exponential",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromObject(value map[string]any, key string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, _ := value[key].(string)
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromAny(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(typed)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,9 +31,18 @@ func (s *Store) ReserveRateLimits(ctx context.Context, taskID string, attemptID
|
|||||||
}
|
}
|
||||||
if reservation.Metric == "" || reservation.Amount > reservation.Limit {
|
if reservation.Metric == "" || reservation.Amount > reservation.Limit {
|
||||||
return RateLimitResult{}, &RateLimitExceededError{
|
return RateLimitResult{}, &RateLimitExceededError{
|
||||||
Metric: reservation.Metric,
|
ScopeType: reservation.ScopeType,
|
||||||
Message: fmt.Sprintf("rate limit exceeded: %s request amount %.0f is greater than limit %.0f", reservation.Metric, reservation.Amount, reservation.Limit),
|
ScopeKey: reservation.ScopeKey,
|
||||||
Retryable: false,
|
ScopeName: reservation.ScopeName,
|
||||||
|
ScopeMetadata: reservation.ScopeMetadata,
|
||||||
|
Metric: reservation.Metric,
|
||||||
|
Limit: reservation.Limit,
|
||||||
|
Amount: reservation.Amount,
|
||||||
|
Projected: reservation.Amount,
|
||||||
|
WindowSeconds: reservation.WindowSeconds,
|
||||||
|
Policy: reservation.Policy,
|
||||||
|
Message: fmt.Sprintf("rate limit exceeded: %s request amount %.0f is greater than limit %.0f", reservation.Metric, reservation.Amount, reservation.Limit),
|
||||||
|
Retryable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reservation.WindowSeconds <= 0 {
|
if reservation.WindowSeconds <= 0 {
|
||||||
@ -78,10 +87,22 @@ WHERE scope_type = $1
|
|||||||
}
|
}
|
||||||
if active+reservation.Amount > reservation.Limit {
|
if active+reservation.Amount > reservation.Limit {
|
||||||
return "", &RateLimitExceededError{
|
return "", &RateLimitExceededError{
|
||||||
Metric: reservation.Metric,
|
ScopeType: reservation.ScopeType,
|
||||||
Message: fmt.Sprintf("rate limit exceeded: concurrent active %.0f plus request %.0f is greater than limit %.0f", active, reservation.Amount, reservation.Limit),
|
ScopeKey: reservation.ScopeKey,
|
||||||
RetryAfter: concurrencyRetryAfter(nextAvailableAt),
|
ScopeName: reservation.ScopeName,
|
||||||
Retryable: true,
|
ScopeMetadata: reservation.ScopeMetadata,
|
||||||
|
Metric: reservation.Metric,
|
||||||
|
Limit: reservation.Limit,
|
||||||
|
Amount: reservation.Amount,
|
||||||
|
Current: active,
|
||||||
|
Used: active,
|
||||||
|
Projected: active + reservation.Amount,
|
||||||
|
WindowSeconds: reservation.WindowSeconds,
|
||||||
|
ResetAt: nextAvailableAt,
|
||||||
|
Policy: reservation.Policy,
|
||||||
|
Message: fmt.Sprintf("rate limit exceeded: concurrent active %.0f plus request %.0f is greater than limit %.0f", active, reservation.Amount, reservation.Limit),
|
||||||
|
RetryAfter: concurrencyRetryAfter(nextAvailableAt),
|
||||||
|
Retryable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var leaseID string
|
var leaseID string
|
||||||
@ -135,11 +156,13 @@ RETURNING window_start`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
resetAt := time.Now().Add(time.Duration(reservation.WindowSeconds) * time.Second)
|
resetAt := time.Now().Add(time.Duration(reservation.WindowSeconds) * time.Second)
|
||||||
|
currentUsed := 0.0
|
||||||
|
currentReserved := 0.0
|
||||||
_ = tx.QueryRow(ctx, `
|
_ = tx.QueryRow(ctx, `
|
||||||
WITH bounds AS (
|
WITH bounds AS (
|
||||||
SELECT to_timestamp(floor(extract(epoch FROM now()) / $4::int) * $4::int) AS window_start
|
SELECT to_timestamp(floor(extract(epoch FROM now()) / $4::int) * $4::int) AS window_start
|
||||||
)
|
)
|
||||||
SELECT counters.reset_at
|
SELECT counters.used_value::float8, counters.reserved_value::float8, counters.reset_at
|
||||||
FROM gateway_rate_limit_counters counters
|
FROM gateway_rate_limit_counters counters
|
||||||
JOIN bounds ON counters.window_start = bounds.window_start
|
JOIN bounds ON counters.window_start = bounds.window_start
|
||||||
WHERE scope_type = $1
|
WHERE scope_type = $1
|
||||||
@ -149,12 +172,26 @@ WHERE scope_type = $1
|
|||||||
reservation.ScopeKey,
|
reservation.ScopeKey,
|
||||||
reservation.Metric,
|
reservation.Metric,
|
||||||
reservation.WindowSeconds,
|
reservation.WindowSeconds,
|
||||||
).Scan(&resetAt)
|
).Scan(¤tUsed, ¤tReserved, &resetAt)
|
||||||
|
current := currentUsed + currentReserved
|
||||||
return RateLimitReservation{}, &RateLimitExceededError{
|
return RateLimitReservation{}, &RateLimitExceededError{
|
||||||
Metric: reservation.Metric,
|
ScopeType: reservation.ScopeType,
|
||||||
Message: fmt.Sprintf("rate limit exceeded: %s window has no remaining capacity", reservation.Metric),
|
ScopeKey: reservation.ScopeKey,
|
||||||
RetryAfter: retryAfterUntil(resetAt),
|
ScopeName: reservation.ScopeName,
|
||||||
Retryable: true,
|
ScopeMetadata: reservation.ScopeMetadata,
|
||||||
|
Metric: reservation.Metric,
|
||||||
|
Limit: reservation.Limit,
|
||||||
|
Amount: reservation.Amount,
|
||||||
|
Current: current,
|
||||||
|
Used: currentUsed,
|
||||||
|
Reserved: currentReserved,
|
||||||
|
Projected: current + reservation.Amount,
|
||||||
|
WindowSeconds: reservation.WindowSeconds,
|
||||||
|
ResetAt: resetAt,
|
||||||
|
Policy: reservation.Policy,
|
||||||
|
Message: fmt.Sprintf("rate limit exceeded: %s window has no remaining capacity", reservation.Metric),
|
||||||
|
RetryAfter: retryAfterUntil(resetAt),
|
||||||
|
Retryable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return RateLimitReservation{}, err
|
return RateLimitReservation{}, err
|
||||||
|
|||||||
@ -33,10 +33,23 @@ func ModelCandidateErrorCode(err error) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RateLimitExceededError struct {
|
type RateLimitExceededError struct {
|
||||||
Metric string
|
ScopeType string
|
||||||
Message string
|
ScopeKey string
|
||||||
RetryAfter time.Duration
|
ScopeName string
|
||||||
Retryable bool
|
ScopeMetadata map[string]any
|
||||||
|
Metric string
|
||||||
|
Limit float64
|
||||||
|
Amount float64
|
||||||
|
Current float64
|
||||||
|
Used float64
|
||||||
|
Reserved float64
|
||||||
|
Projected float64
|
||||||
|
WindowSeconds int
|
||||||
|
ResetAt time.Time
|
||||||
|
Policy map[string]any
|
||||||
|
Message string
|
||||||
|
RetryAfter time.Duration
|
||||||
|
Retryable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *RateLimitExceededError) Error() string {
|
func (e *RateLimitExceededError) Error() string {
|
||||||
@ -166,12 +179,15 @@ type RateLimitReservation struct {
|
|||||||
ReservationID string
|
ReservationID string
|
||||||
ScopeType string
|
ScopeType string
|
||||||
ScopeKey string
|
ScopeKey string
|
||||||
|
ScopeName string
|
||||||
|
ScopeMetadata map[string]any
|
||||||
Metric string
|
Metric string
|
||||||
Limit float64
|
Limit float64
|
||||||
Amount float64
|
Amount float64
|
||||||
WindowSeconds int
|
WindowSeconds int
|
||||||
LeaseTTLSeconds int
|
LeaseTTLSeconds int
|
||||||
WindowStart time.Time
|
WindowStart time.Time
|
||||||
|
Policy map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
type RateLimitResult struct {
|
type RateLimitResult struct {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
type UserGroupPolicy struct {
|
type UserGroupPolicy struct {
|
||||||
ID string
|
ID string
|
||||||
GroupKey string
|
GroupKey string
|
||||||
|
Name string
|
||||||
RateLimitPolicy map[string]any
|
RateLimitPolicy map[string]any
|
||||||
BillingDiscountPolicy map[string]any
|
BillingDiscountPolicy map[string]any
|
||||||
}
|
}
|
||||||
@ -23,12 +24,12 @@ func (s *Store) ResolveUserGroupPolicy(ctx context.Context, user *auth.User) (Us
|
|||||||
var rateLimit []byte
|
var rateLimit []byte
|
||||||
var billing []byte
|
var billing []byte
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT id::text, group_key, rate_limit_policy, billing_discount_policy
|
SELECT id::text, group_key, name, rate_limit_policy, billing_discount_policy
|
||||||
FROM gateway_user_groups
|
FROM gateway_user_groups
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
AND (($1 <> '' AND id = NULLIF($1, '')::uuid) OR ($1 = '' AND group_key = 'default'))
|
AND (($1 <> '' AND id = NULLIF($1, '')::uuid) OR ($1 = '' AND group_key = 'default'))
|
||||||
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END, priority ASC
|
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END, priority ASC
|
||||||
LIMIT 1`, userGroupID).Scan(&item.ID, &item.GroupKey, &rateLimit, &billing)
|
LIMIT 1`, userGroupID).Scan(&item.ID, &item.GroupKey, &item.Name, &rateLimit, &billing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
return UserGroupPolicy{}, nil
|
return UserGroupPolicy{}, nil
|
||||||
|
|||||||
85
apps/api/migrations/0036_file_storage_channels.sql
Normal file
85
apps/api/migrations/0036_file_storage_channels.sql
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
setting_key text PRIMARY KEY,
|
||||||
|
value jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO system_settings (setting_key, value)
|
||||||
|
VALUES (
|
||||||
|
'file_storage',
|
||||||
|
'{"resultUploadPolicy": "default"}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS file_storage_channels (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_key text NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL,
|
||||||
|
provider text NOT NULL DEFAULT 'server_main_openapi',
|
||||||
|
upload_url text,
|
||||||
|
credentials jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
retry_policy jsonb NOT NULL DEFAULT '{
|
||||||
|
"enabled": true,
|
||||||
|
"maxRetries": 3,
|
||||||
|
"backoffSeconds": [60, 120, 180],
|
||||||
|
"strategy": "exponential"
|
||||||
|
}'::jsonb,
|
||||||
|
priority integer NOT NULL DEFAULT 100,
|
||||||
|
status text NOT NULL DEFAULT 'disabled',
|
||||||
|
last_error text,
|
||||||
|
last_failed_at timestamptz,
|
||||||
|
last_succeeded_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS file_storage_channels
|
||||||
|
ADD COLUMN IF NOT EXISTS upload_url text,
|
||||||
|
ADD COLUMN IF NOT EXISTS config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS retry_policy jsonb NOT NULL DEFAULT '{
|
||||||
|
"enabled": true,
|
||||||
|
"maxRetries": 3,
|
||||||
|
"backoffSeconds": [60, 120, 180],
|
||||||
|
"strategy": "exponential"
|
||||||
|
}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS priority integer NOT NULL DEFAULT 100,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_error text,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_failed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_succeeded_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_file_storage_channels_active
|
||||||
|
ON file_storage_channels (status, priority, created_at)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO file_storage_channels (
|
||||||
|
channel_key,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
upload_url,
|
||||||
|
credentials,
|
||||||
|
config,
|
||||||
|
retry_policy,
|
||||||
|
priority,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'server-main-openapi',
|
||||||
|
'server-main OpenAPI',
|
||||||
|
'server_main_openapi',
|
||||||
|
'http://127.0.0.1:3001/v1/files/upload',
|
||||||
|
'{}'::jsonb,
|
||||||
|
'{"scenes": ["upload", "image_result"]}'::jsonb,
|
||||||
|
'{
|
||||||
|
"enabled": true,
|
||||||
|
"maxRetries": 3,
|
||||||
|
"backoffSeconds": [60, 120, 180],
|
||||||
|
"strategy": "exponential"
|
||||||
|
}'::jsonb,
|
||||||
|
100,
|
||||||
|
'disabled'
|
||||||
|
)
|
||||||
|
ON CONFLICT (channel_key) DO NOTHING;
|
||||||
13
apps/api/migrations/0037_file_storage_settings.sql
Normal file
13
apps/api/migrations/0037_file_storage_settings.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
setting_key text PRIMARY KEY,
|
||||||
|
value jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO system_settings (setting_key, value)
|
||||||
|
VALUES (
|
||||||
|
'file_storage',
|
||||||
|
'{"resultUploadPolicy": "default"}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
|||||||
import type {
|
import type {
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
|
FileStorageChannel,
|
||||||
|
FileStorageSettings,
|
||||||
|
FileStorageSettingsUpdateRequest,
|
||||||
|
FileStorageChannelUpsertRequest,
|
||||||
GatewayAccessRuleBatchRequest,
|
GatewayAccessRuleBatchRequest,
|
||||||
GatewayAccessRule,
|
GatewayAccessRule,
|
||||||
GatewayAccessRuleUpsertRequest,
|
GatewayAccessRuleUpsertRequest,
|
||||||
@ -34,17 +38,22 @@ import {
|
|||||||
batchApiKeyAccessRules,
|
batchApiKeyAccessRules,
|
||||||
createAccessRule,
|
createAccessRule,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
|
createFileStorageChannel,
|
||||||
createGatewayUser,
|
createGatewayUser,
|
||||||
createPlatform,
|
createPlatform,
|
||||||
createTenant,
|
createTenant,
|
||||||
createUserGroup,
|
createUserGroup,
|
||||||
deleteAccessRule,
|
deleteAccessRule,
|
||||||
deleteApiKey,
|
deleteApiKey,
|
||||||
|
deleteFileStorageChannel,
|
||||||
deleteGatewayUser,
|
deleteGatewayUser,
|
||||||
deletePlatform,
|
deletePlatform,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
deleteUserGroup,
|
deleteUserGroup,
|
||||||
|
GatewayApiError,
|
||||||
getHealth,
|
getHealth,
|
||||||
|
listFileStorageChannels,
|
||||||
|
getFileStorageSettings,
|
||||||
getNetworkProxyConfig,
|
getNetworkProxyConfig,
|
||||||
getRunnerPolicy,
|
getRunnerPolicy,
|
||||||
getWalletSummary,
|
getWalletSummary,
|
||||||
@ -78,6 +87,8 @@ import {
|
|||||||
setUserWalletBalance,
|
setUserWalletBalance,
|
||||||
type HealthResponse,
|
type HealthResponse,
|
||||||
updateAccessRule,
|
updateAccessRule,
|
||||||
|
updateFileStorageChannel,
|
||||||
|
updateFileStorageSettings,
|
||||||
updateGatewayUser,
|
updateGatewayUser,
|
||||||
updatePlatform,
|
updatePlatform,
|
||||||
updatePlatformDynamicPriority,
|
updatePlatformDynamicPriority,
|
||||||
@ -135,6 +146,8 @@ type DataKey =
|
|||||||
| 'playgroundModels'
|
| 'playgroundModels'
|
||||||
| 'modelCatalog'
|
| 'modelCatalog'
|
||||||
| 'networkProxyConfig'
|
| 'networkProxyConfig'
|
||||||
|
| 'fileStorageChannels'
|
||||||
|
| 'fileStorageSettings'
|
||||||
| 'platforms'
|
| 'platforms'
|
||||||
| 'models'
|
| 'models'
|
||||||
| 'providers'
|
| 'providers'
|
||||||
@ -179,6 +192,8 @@ export function App() {
|
|||||||
});
|
});
|
||||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||||
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
||||||
|
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
|
||||||
|
const [fileStorageSettings, setFileStorageSettings] = useState<FileStorageSettings | null>(null);
|
||||||
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
||||||
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
||||||
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
||||||
@ -257,7 +272,8 @@ export function App() {
|
|||||||
loadedDataKeysRef.current.add('modelRateLimits');
|
loadedDataKeysRef.current.add('modelRateLimits');
|
||||||
loadedDataKeysRef.current.add('platforms');
|
loadedDataKeysRef.current.add('platforms');
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
|
if (handleAuthExpired(err, token)) return;
|
||||||
loadedDataKeysRef.current.delete('modelRateLimits');
|
loadedDataKeysRef.current.delete('modelRateLimits');
|
||||||
loadedDataKeysRef.current.delete('platforms');
|
loadedDataKeysRef.current.delete('platforms');
|
||||||
});
|
});
|
||||||
@ -296,6 +312,8 @@ export function App() {
|
|||||||
auditLogs,
|
auditLogs,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
baseModels,
|
baseModels,
|
||||||
|
fileStorageChannels,
|
||||||
|
fileStorageSettings,
|
||||||
modelCatalog,
|
modelCatalog,
|
||||||
models,
|
models,
|
||||||
networkProxyConfig,
|
networkProxyConfig,
|
||||||
@ -315,7 +333,7 @@ export function App() {
|
|||||||
users,
|
users,
|
||||||
walletAccounts,
|
walletAccounts,
|
||||||
walletTransactions,
|
walletTransactions,
|
||||||
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
|
}), [accessRules, apiKeys, auditLogs, baseModels, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
|
||||||
|
|
||||||
async function refresh(nextToken = token) {
|
async function refresh(nextToken = token) {
|
||||||
await ensureRouteData(nextToken, true);
|
await ensureRouteData(nextToken, true);
|
||||||
@ -354,6 +372,7 @@ export function App() {
|
|||||||
requestKeys.forEach((key) => loadedDataKeysRef.current.add(key));
|
requestKeys.forEach((key) => loadedDataKeysRef.current.add(key));
|
||||||
setState('ready');
|
setState('ready');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (handleAuthExpired(err, nextToken)) return;
|
||||||
setState('error');
|
setState('error');
|
||||||
setError(err instanceof Error ? err.message : '加载失败');
|
setError(err instanceof Error ? err.message : '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@ -388,6 +407,12 @@ export function App() {
|
|||||||
case 'networkProxyConfig':
|
case 'networkProxyConfig':
|
||||||
setNetworkProxyConfig(await getNetworkProxyConfig(nextToken));
|
setNetworkProxyConfig(await getNetworkProxyConfig(nextToken));
|
||||||
return;
|
return;
|
||||||
|
case 'fileStorageChannels':
|
||||||
|
setFileStorageChannels((await listFileStorageChannels(nextToken)).items);
|
||||||
|
return;
|
||||||
|
case 'fileStorageSettings':
|
||||||
|
setFileStorageSettings(await getFileStorageSettings(nextToken));
|
||||||
|
return;
|
||||||
case 'playgroundModels':
|
case 'playgroundModels':
|
||||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||||
return;
|
return;
|
||||||
@ -818,6 +843,53 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveFileStorageChannel(input: FileStorageChannelUpsertRequest, channelId?: string) {
|
||||||
|
setCoreState('loading');
|
||||||
|
setCoreMessage('');
|
||||||
|
try {
|
||||||
|
const item = channelId
|
||||||
|
? await updateFileStorageChannel(token, channelId, input)
|
||||||
|
: await createFileStorageChannel(token, input);
|
||||||
|
setFileStorageChannels((current) => [item, ...current.filter((channel) => channel.id !== item.id)]);
|
||||||
|
setCoreState('ready');
|
||||||
|
setCoreMessage(channelId ? '文件存储渠道已更新。' : '文件存储渠道已新增。');
|
||||||
|
} catch (err) {
|
||||||
|
setCoreState('error');
|
||||||
|
setCoreMessage(err instanceof Error ? err.message : channelId ? '更新文件存储渠道失败' : '新增文件存储渠道失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFileStorageSettings(input: FileStorageSettingsUpdateRequest) {
|
||||||
|
setCoreState('loading');
|
||||||
|
setCoreMessage('');
|
||||||
|
try {
|
||||||
|
const settings = await updateFileStorageSettings(token, input);
|
||||||
|
setFileStorageSettings(settings);
|
||||||
|
setCoreState('ready');
|
||||||
|
setCoreMessage('文件存储全局策略已更新。');
|
||||||
|
} catch (err) {
|
||||||
|
setCoreState('error');
|
||||||
|
setCoreMessage(err instanceof Error ? err.message : '更新文件存储全局策略失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFileStorageChannel(channelId: string) {
|
||||||
|
setCoreState('loading');
|
||||||
|
setCoreMessage('');
|
||||||
|
try {
|
||||||
|
await deleteFileStorageChannel(token, channelId);
|
||||||
|
setFileStorageChannels((current) => current.filter((channel) => channel.id !== channelId));
|
||||||
|
setCoreState('ready');
|
||||||
|
setCoreMessage('文件存储渠道已删除。');
|
||||||
|
} catch (err) {
|
||||||
|
setCoreState('error');
|
||||||
|
setCoreMessage(err instanceof Error ? err.message : '删除文件存储渠道失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function batchSaveAPIKeyAccessRules(input: GatewayAccessRuleBatchRequest) {
|
async function batchSaveAPIKeyAccessRules(input: GatewayAccessRuleBatchRequest) {
|
||||||
setCoreState('loading');
|
setCoreState('loading');
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
@ -856,7 +928,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function signOut() {
|
function resetAuthenticatedSession() {
|
||||||
persistAccessToken('');
|
persistAccessToken('');
|
||||||
setToken('');
|
setToken('');
|
||||||
loadedDataKeysRef.current = new Set(health ? ['health'] : []);
|
loadedDataKeysRef.current = new Set(health ? ['health'] : []);
|
||||||
@ -867,6 +939,7 @@ export function App() {
|
|||||||
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
||||||
setPlaygroundModels([]);
|
setPlaygroundModels([]);
|
||||||
setNetworkProxyConfig(null);
|
setNetworkProxyConfig(null);
|
||||||
|
setFileStorageChannels([]);
|
||||||
setProviders([]);
|
setProviders([]);
|
||||||
setBaseModels([]);
|
setBaseModels([]);
|
||||||
setPricingRules([]);
|
setPricingRules([]);
|
||||||
@ -892,6 +965,19 @@ export function App() {
|
|||||||
setWalletTransactionTotal(0);
|
setWalletTransactionTotal(0);
|
||||||
setWorkspaceTransactionQuery(defaultWorkspaceTransactionQuery());
|
setWorkspaceTransactionQuery(defaultWorkspaceTransactionQuery());
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthExpired(err: unknown, failedToken: string) {
|
||||||
|
if (!failedToken || !(err instanceof GatewayApiError) || err.details.status !== 401) return false;
|
||||||
|
resetAuthenticatedSession();
|
||||||
|
setError('');
|
||||||
|
setAuthMode('login');
|
||||||
|
navigatePath(pathForWorkspaceSection('overview'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
resetAuthenticatedSession();
|
||||||
navigatePath('/');
|
navigatePath('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1039,6 +1125,7 @@ export function App() {
|
|||||||
onDeletePricingRuleSet={removePricingRuleSet}
|
onDeletePricingRuleSet={removePricingRuleSet}
|
||||||
onDeleteRuntimePolicySet={removeRuntimePolicySet}
|
onDeleteRuntimePolicySet={removeRuntimePolicySet}
|
||||||
onDeleteAccessRule={removeAccessRule}
|
onDeleteAccessRule={removeAccessRule}
|
||||||
|
onDeleteFileStorageChannel={removeFileStorageChannel}
|
||||||
onDeleteTenant={removeTenant}
|
onDeleteTenant={removeTenant}
|
||||||
onDeleteUser={removeUser}
|
onDeleteUser={removeUser}
|
||||||
onDeleteUserGroup={removeUserGroup}
|
onDeleteUserGroup={removeUserGroup}
|
||||||
@ -1054,6 +1141,8 @@ export function App() {
|
|||||||
onSaveRuntimePolicySet={saveRuntimePolicySet}
|
onSaveRuntimePolicySet={saveRuntimePolicySet}
|
||||||
onBatchAccessRules={batchSaveAccessRules}
|
onBatchAccessRules={batchSaveAccessRules}
|
||||||
onSaveAccessRule={saveAccessRule}
|
onSaveAccessRule={saveAccessRule}
|
||||||
|
onSaveFileStorageChannel={saveFileStorageChannel}
|
||||||
|
onSaveFileStorageSettings={saveFileStorageSettings}
|
||||||
onSaveTenant={saveTenant}
|
onSaveTenant={saveTenant}
|
||||||
onSaveUser={saveUser}
|
onSaveUser={saveUser}
|
||||||
onSetUserWalletBalance={saveUserWalletBalance}
|
onSetUserWalletBalance={saveUserWalletBalance}
|
||||||
@ -1267,6 +1356,8 @@ function dataKeysForRoute(
|
|||||||
return ['auditLogs'];
|
return ['auditLogs'];
|
||||||
case 'accessRules':
|
case 'accessRules':
|
||||||
return ['accessRules', 'userGroups', 'platforms', 'models'];
|
return ['accessRules', 'userGroups', 'platforms', 'models'];
|
||||||
|
case 'systemSettings':
|
||||||
|
return ['fileStorageSettings', 'fileStorageChannels'];
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,10 @@ import type {
|
|||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
CatalogProviderUpsertRequest,
|
CatalogProviderUpsertRequest,
|
||||||
CreatedGatewayApiKey,
|
CreatedGatewayApiKey,
|
||||||
|
FileStorageChannel,
|
||||||
|
FileStorageSettings,
|
||||||
|
FileStorageSettingsUpdateRequest,
|
||||||
|
FileStorageChannelUpsertRequest,
|
||||||
GatewayAccessRuleBatchRequest,
|
GatewayAccessRuleBatchRequest,
|
||||||
GatewayAccessRule,
|
GatewayAccessRule,
|
||||||
GatewayAccessRuleUpsertRequest,
|
GatewayAccessRuleUpsertRequest,
|
||||||
@ -15,6 +19,7 @@ import type {
|
|||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
GatewayTenantUpsertRequest,
|
GatewayTenantUpsertRequest,
|
||||||
GatewayNetworkProxyConfig,
|
GatewayNetworkProxyConfig,
|
||||||
|
GatewayPricingEstimate,
|
||||||
GatewayTask,
|
GatewayTask,
|
||||||
GatewayTaskParamPreprocessingLog,
|
GatewayTaskParamPreprocessingLog,
|
||||||
GatewayUser,
|
GatewayUser,
|
||||||
@ -601,10 +606,17 @@ export async function createImageGenerationTask(
|
|||||||
model: string;
|
model: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
|
content?: Array<Record<string, unknown>>;
|
||||||
count?: number;
|
count?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
image?: string | string[];
|
||||||
|
image_url?: string | string[];
|
||||||
|
image_urls?: string[];
|
||||||
|
images?: string[];
|
||||||
n?: number;
|
n?: number;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
|
referenceImage?: string | string[];
|
||||||
|
reference_image?: string | string[];
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
runMode?: string;
|
runMode?: string;
|
||||||
simulation?: boolean;
|
simulation?: boolean;
|
||||||
@ -622,7 +634,26 @@ export async function createImageGenerationTask(
|
|||||||
|
|
||||||
export async function createImageEditTask(
|
export async function createImageEditTask(
|
||||||
token: string,
|
token: string,
|
||||||
input: { model: string; prompt: string; image?: string; mask?: string; runMode?: string; simulation?: boolean },
|
input: {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
aspect_ratio?: string;
|
||||||
|
content?: Array<Record<string, unknown>>;
|
||||||
|
count?: number;
|
||||||
|
height?: number;
|
||||||
|
image?: string | string[];
|
||||||
|
image_url?: string | string[];
|
||||||
|
image_urls?: string[];
|
||||||
|
images?: string[];
|
||||||
|
mask?: string;
|
||||||
|
n?: number;
|
||||||
|
quality?: string;
|
||||||
|
resolution?: string;
|
||||||
|
runMode?: string;
|
||||||
|
simulation?: boolean;
|
||||||
|
size?: string;
|
||||||
|
width?: number;
|
||||||
|
},
|
||||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/edits', {
|
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/edits', {
|
||||||
body: input,
|
body: input,
|
||||||
@ -632,25 +663,83 @@ export async function createImageEditTask(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VideoGenerationContentRole =
|
||||||
|
| 'first_frame'
|
||||||
|
| 'last_frame'
|
||||||
|
| 'reference_image'
|
||||||
|
| 'reference_video'
|
||||||
|
| 'reference_audio'
|
||||||
|
| 'digital_human_frame'
|
||||||
|
| 'reference'
|
||||||
|
| 'element'
|
||||||
|
| 'video_feature'
|
||||||
|
| 'video_base'
|
||||||
|
| 'shot_prompt';
|
||||||
|
|
||||||
|
export interface VideoGenerationContent {
|
||||||
|
type: 'text' | 'image_url' | 'audio_url' | 'video_url' | 'element';
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
video_url?: {
|
||||||
|
url: string;
|
||||||
|
refer_type?: 'feature' | 'base';
|
||||||
|
keep_original_sound?: 'yes' | 'no';
|
||||||
|
};
|
||||||
|
audio_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
role?: VideoGenerationContentRole;
|
||||||
|
shot_index?: number;
|
||||||
|
duration?: number;
|
||||||
|
name?: string;
|
||||||
|
element?: {
|
||||||
|
system_element_id?: string;
|
||||||
|
inline_element?: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
frontal_image_url: string;
|
||||||
|
refer_images: Array<{ url: string; slot_key?: string }>;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoGenerationParams {
|
||||||
|
content: VideoGenerationContent[];
|
||||||
|
model: string;
|
||||||
|
aspect_ratio?: string;
|
||||||
|
resolution?: string;
|
||||||
|
duration?: number;
|
||||||
|
audio_list?: Array<{
|
||||||
|
url?: string;
|
||||||
|
audio_url?: string;
|
||||||
|
name?: string;
|
||||||
|
}>;
|
||||||
|
audio?: boolean;
|
||||||
|
framespersecond?: number;
|
||||||
|
watermark?: boolean;
|
||||||
|
seed?: number;
|
||||||
|
camerafixed?: boolean;
|
||||||
|
camera_control?: string;
|
||||||
|
camera_control_strength?: number;
|
||||||
|
prompt_extend?: boolean;
|
||||||
|
size?: string;
|
||||||
|
task_id?: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
histories?: string;
|
||||||
|
callback_url?: string;
|
||||||
|
prompt_optimizer?: boolean;
|
||||||
|
fast_pretreatment?: boolean;
|
||||||
|
mode?: 'std' | 'pro';
|
||||||
|
negative_prompt?: string;
|
||||||
|
cfg_scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createVideoGenerationTask(
|
export async function createVideoGenerationTask(
|
||||||
token: string,
|
token: string,
|
||||||
input: {
|
input: VideoGenerationParams,
|
||||||
audio?: boolean;
|
|
||||||
model: string;
|
|
||||||
prompt: string;
|
|
||||||
aspect_ratio?: string;
|
|
||||||
count?: number;
|
|
||||||
duration?: number;
|
|
||||||
duration_seconds?: number;
|
|
||||||
height?: number;
|
|
||||||
n?: number;
|
|
||||||
output_audio?: boolean;
|
|
||||||
resolution?: string;
|
|
||||||
runMode?: string;
|
|
||||||
simulation?: boolean;
|
|
||||||
size?: string;
|
|
||||||
width?: number;
|
|
||||||
},
|
|
||||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/videos/generations', {
|
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/videos/generations', {
|
||||||
body: input,
|
body: input,
|
||||||
@ -660,11 +749,46 @@ export async function createVideoGenerationTask(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayFileUploadResponse extends Record<string, unknown> {
|
||||||
|
fileUrl?: string;
|
||||||
|
file_url?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileToStorage(
|
||||||
|
token: string,
|
||||||
|
file: File,
|
||||||
|
source = 'ai-gateway-playground',
|
||||||
|
): Promise<GatewayFileUploadResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('source', source);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/v1/files/upload`, {
|
||||||
|
body: form,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`));
|
||||||
|
}
|
||||||
|
if (!body) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as unknown;
|
||||||
|
return recordFromUnknown(parsed) ? (parsed as GatewayFileUploadResponse) : {};
|
||||||
|
} catch {
|
||||||
|
return { url: body };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function estimatePricing(
|
export async function estimatePricing(
|
||||||
token: string,
|
token: string,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
): Promise<{ items: unknown[]; resolver: string }> {
|
): Promise<GatewayPricingEstimate> {
|
||||||
return request<{ items: unknown[]; resolver: string }>('/api/v1/pricing/estimate', {
|
return request<GatewayPricingEstimate>('/api/v1/pricing/estimate', {
|
||||||
body: input,
|
body: input,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
@ -758,6 +882,55 @@ export async function getNetworkProxyConfig(token: string): Promise<GatewayNetwo
|
|||||||
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
|
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listFileStorageChannels(token: string): Promise<ListResponse<FileStorageChannel>> {
|
||||||
|
return request<ListResponse<FileStorageChannel>>('/api/admin/system/file-storage/channels', { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileStorageSettings(token: string): Promise<FileStorageSettings> {
|
||||||
|
return request<FileStorageSettings>('/api/admin/system/file-storage/settings', { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFileStorageSettings(
|
||||||
|
token: string,
|
||||||
|
input: FileStorageSettingsUpdateRequest,
|
||||||
|
): Promise<FileStorageSettings> {
|
||||||
|
return request<FileStorageSettings>('/api/admin/system/file-storage/settings', {
|
||||||
|
body: input,
|
||||||
|
method: 'PATCH',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFileStorageChannel(
|
||||||
|
token: string,
|
||||||
|
input: FileStorageChannelUpsertRequest,
|
||||||
|
): Promise<FileStorageChannel> {
|
||||||
|
return request<FileStorageChannel>('/api/admin/system/file-storage/channels', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFileStorageChannel(
|
||||||
|
token: string,
|
||||||
|
channelId: string,
|
||||||
|
input: FileStorageChannelUpsertRequest,
|
||||||
|
): Promise<FileStorageChannel> {
|
||||||
|
return request<FileStorageChannel>(`/api/admin/system/file-storage/channels/${channelId}`, {
|
||||||
|
body: input,
|
||||||
|
method: 'PATCH',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFileStorageChannel(token: string, channelId: string): Promise<void> {
|
||||||
|
await request<void>(`/api/admin/system/file-storage/channels/${channelId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
|
FileStorageChannel,
|
||||||
|
FileStorageSettings,
|
||||||
GatewayAccessRule,
|
GatewayAccessRule,
|
||||||
GatewayApiKey,
|
GatewayApiKey,
|
||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
@ -27,6 +29,8 @@ export interface ConsoleData {
|
|||||||
auditLogs: GatewayAuditLog[];
|
auditLogs: GatewayAuditLog[];
|
||||||
apiKeys: GatewayApiKey[];
|
apiKeys: GatewayApiKey[];
|
||||||
baseModels: BaseModelCatalogItem[];
|
baseModels: BaseModelCatalogItem[];
|
||||||
|
fileStorageChannels: FileStorageChannel[];
|
||||||
|
fileStorageSettings: FileStorageSettings | null;
|
||||||
modelCatalog: ModelCatalogResponse;
|
modelCatalog: ModelCatalogResponse;
|
||||||
models: PlatformModel[];
|
models: PlatformModel[];
|
||||||
networkProxyConfig: GatewayNetworkProxyConfig | null;
|
networkProxyConfig: GatewayNetworkProxyConfig | null;
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Boxes, Building2, Gauge, History, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
|
import { Boxes, Building2, Gauge, History, KeyRound, Route, ServerCog, Settings, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
BaseModelUpsertRequest,
|
BaseModelUpsertRequest,
|
||||||
CatalogProviderUpsertRequest,
|
CatalogProviderUpsertRequest,
|
||||||
|
FileStorageChannelUpsertRequest,
|
||||||
|
FileStorageSettingsUpdateRequest,
|
||||||
GatewayAccessRuleBatchRequest,
|
GatewayAccessRuleBatchRequest,
|
||||||
GatewayAccessRuleUpsertRequest,
|
GatewayAccessRuleUpsertRequest,
|
||||||
GatewayTenantUpsertRequest,
|
GatewayTenantUpsertRequest,
|
||||||
@ -29,6 +31,7 @@ import { PricingRulesPanel } from './admin/PricingRulesPanel';
|
|||||||
import { ProviderManagementPanel } from './admin/ProviderManagementPanel';
|
import { ProviderManagementPanel } from './admin/ProviderManagementPanel';
|
||||||
import { RealtimeLoadPanel } from './admin/RealtimeLoadPanel';
|
import { RealtimeLoadPanel } from './admin/RealtimeLoadPanel';
|
||||||
import { RuntimePoliciesPanel } from './admin/RuntimePoliciesPanel';
|
import { RuntimePoliciesPanel } from './admin/RuntimePoliciesPanel';
|
||||||
|
import { SystemSettingsPanel } from './admin/SystemSettingsPanel';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'overview', label: '总览', icon: <Workflow size={15} /> },
|
{ value: 'overview', label: '总览', icon: <Workflow size={15} /> },
|
||||||
@ -42,6 +45,7 @@ const tabs = [
|
|||||||
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
|
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
|
||||||
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
|
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
|
||||||
{ value: 'accessRules', label: '模型权限', icon: <KeyRound size={15} /> },
|
{ value: 'accessRules', label: '模型权限', icon: <KeyRound size={15} /> },
|
||||||
|
{ value: 'systemSettings', label: '系统设置', icon: <Settings size={15} /> },
|
||||||
{ value: 'auditLogs', label: '审计日志', icon: <History size={15} /> },
|
{ value: 'auditLogs', label: '审计日志', icon: <History size={15} /> },
|
||||||
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
|
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
|
||||||
|
|
||||||
@ -57,6 +61,7 @@ export function AdminPage(props: {
|
|||||||
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
|
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
|
||||||
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
|
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
|
||||||
onDeleteAccessRule: (ruleId: string) => Promise<void>;
|
onDeleteAccessRule: (ruleId: string) => Promise<void>;
|
||||||
|
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
|
||||||
onDeleteTenant: (tenantId: string) => Promise<void>;
|
onDeleteTenant: (tenantId: string) => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||||
@ -72,6 +77,8 @@ export function AdminPage(props: {
|
|||||||
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
|
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
|
||||||
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
|
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
|
||||||
onSaveAccessRule: (input: GatewayAccessRuleUpsertRequest, ruleId?: string) => Promise<void>;
|
onSaveAccessRule: (input: GatewayAccessRuleUpsertRequest, ruleId?: string) => Promise<void>;
|
||||||
|
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||||
|
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
||||||
@ -172,6 +179,17 @@ export function AdminPage(props: {
|
|||||||
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
|
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
|
||||||
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
|
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
|
||||||
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
|
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
|
||||||
|
{props.section === 'systemSettings' && (
|
||||||
|
<SystemSettingsPanel
|
||||||
|
channels={props.data.fileStorageChannels}
|
||||||
|
settings={props.data.fileStorageSettings}
|
||||||
|
message={props.operationMessage}
|
||||||
|
state={props.state}
|
||||||
|
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
|
||||||
|
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
|
||||||
|
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export function ApiDocsPage(props: {
|
|||||||
onTaskFormChange: (value: TaskForm) => void;
|
onTaskFormChange: (value: TaskForm) => void;
|
||||||
}) {
|
}) {
|
||||||
const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0];
|
const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0];
|
||||||
|
const isFileDoc = current.key === 'files';
|
||||||
const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]);
|
const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]);
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
@ -87,7 +88,7 @@ export function ApiDocsPage(props: {
|
|||||||
<h2>Header 参数</h2>
|
<h2>Header 参数</h2>
|
||||||
<Button type="button" variant="secondary" size="sm">生成代码</Button>
|
<Button type="button" variant="secondary" size="sm">生成代码</Button>
|
||||||
</header>
|
</header>
|
||||||
<ParamRow name="Content-Type" type="string" required value="application/json" />
|
<ParamRow name="Content-Type" type="string" required value={isFileDoc ? 'multipart/form-data' : 'application/json'} />
|
||||||
<ParamRow name="Accept" type="string" required value="application/json" />
|
<ParamRow name="Accept" type="string" required value="application/json" />
|
||||||
<ParamRow name="Authorization" type="string" value="Bearer {{YOUR_API_KEY}}" />
|
<ParamRow name="Authorization" type="string" value="Bearer {{YOUR_API_KEY}}" />
|
||||||
</section>
|
</section>
|
||||||
@ -97,10 +98,19 @@ export function ApiDocsPage(props: {
|
|||||||
<h2>Body 参数</h2>
|
<h2>Body 参数</h2>
|
||||||
<Badge variant="outline">application/json</Badge>
|
<Badge variant="outline">application/json</Badge>
|
||||||
</header>
|
</header>
|
||||||
<ParamRow name="model" type="string" required value="模型 ID 或别名" />
|
{isFileDoc ? (
|
||||||
<ParamRow name="messages / prompt" type="array|string" required value="对话消息或图片提示词" />
|
<>
|
||||||
<ParamRow name="simulation" type="boolean" value="测试模式开关" />
|
<ParamRow name="file" type="file" required value="multipart 文件字段" />
|
||||||
<ParamRow name="stream" type="boolean" value="对话进度流式返回" />
|
<ParamRow name="source" type="string" value="上传来源标记" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ParamRow name="model" type="string" required value="模型 ID 或别名" />
|
||||||
|
<ParamRow name="messages / prompt" type="array|string" required value="对话消息或图片提示词" />
|
||||||
|
<ParamRow name="simulation" type="boolean" value="测试模式开关" />
|
||||||
|
<ParamRow name="stream" type="boolean" value="对话进度流式返回" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -78,8 +78,10 @@ type UserGroupForm = {
|
|||||||
description: string;
|
description: string;
|
||||||
source: string;
|
source: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
rechargeDiscountPolicyJson: string;
|
rechargeDiscountFactor: string;
|
||||||
billingDiscountPolicyJson: string;
|
rechargeDiscountPolicy: Record<string, unknown>;
|
||||||
|
billingDiscountFactor: string;
|
||||||
|
billingDiscountPolicy: Record<string, unknown>;
|
||||||
rateLimitPolicyJson: string;
|
rateLimitPolicyJson: string;
|
||||||
quotaPolicyJson: string;
|
quotaPolicyJson: string;
|
||||||
metadataJson: string;
|
metadataJson: string;
|
||||||
@ -516,8 +518,8 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
|||||||
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userGroupStatuses.map(option)}</Select></Label>
|
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userGroupStatuses.map(option)}</Select></Label>
|
||||||
<Label>优先级<Input size="sm" value={form.priority} inputMode="numeric" onChange={(event) => setForm({ ...form, priority: event.target.value })} /></Label>
|
<Label>优先级<Input size="sm" value={form.priority} inputMode="numeric" onChange={(event) => setForm({ ...form, priority: event.target.value })} /></Label>
|
||||||
<Label className="spanTwo">描述<Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
|
<Label className="spanTwo">描述<Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
|
||||||
<JsonField label="充值折扣策略 JSON" value={form.rechargeDiscountPolicyJson} onChange={(value) => setForm({ ...form, rechargeDiscountPolicyJson: value })} />
|
<Label>充值折扣系数<Input size="sm" value={form.rechargeDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, rechargeDiscountFactor: event.target.value })} /></Label>
|
||||||
<JsonField label="计费折扣策略 JSON" value={form.billingDiscountPolicyJson} onChange={(value) => setForm({ ...form, billingDiscountPolicyJson: value })} />
|
<Label>计费折扣系数<Input size="sm" value={form.billingDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, billingDiscountFactor: event.target.value })} /></Label>
|
||||||
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
|
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
|
||||||
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
|
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
|
||||||
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
||||||
@ -769,8 +771,10 @@ function defaultUserGroupForm(): UserGroupForm {
|
|||||||
description: '',
|
description: '',
|
||||||
source: 'gateway',
|
source: 'gateway',
|
||||||
priority: '100',
|
priority: '100',
|
||||||
rechargeDiscountPolicyJson: '{}',
|
rechargeDiscountFactor: '1',
|
||||||
billingDiscountPolicyJson: '{}',
|
rechargeDiscountPolicy: {},
|
||||||
|
billingDiscountFactor: '1',
|
||||||
|
billingDiscountPolicy: {},
|
||||||
rateLimitPolicyJson: '{"rules":[]}',
|
rateLimitPolicyJson: '{"rules":[]}',
|
||||||
quotaPolicyJson: '{}',
|
quotaPolicyJson: '{}',
|
||||||
metadataJson: '{}',
|
metadataJson: '{}',
|
||||||
@ -785,8 +789,10 @@ function userGroupToForm(group: UserGroup): UserGroupForm {
|
|||||||
description: group.description ?? '',
|
description: group.description ?? '',
|
||||||
source: group.source,
|
source: group.source,
|
||||||
priority: String(group.priority),
|
priority: String(group.priority),
|
||||||
rechargeDiscountPolicyJson: stringifyJson(group.rechargeDiscountPolicy),
|
rechargeDiscountFactor: discountFactorText(group.rechargeDiscountPolicy),
|
||||||
billingDiscountPolicyJson: stringifyJson(group.billingDiscountPolicy),
|
rechargeDiscountPolicy: group.rechargeDiscountPolicy ?? {},
|
||||||
|
billingDiscountFactor: discountFactorText(group.billingDiscountPolicy),
|
||||||
|
billingDiscountPolicy: group.billingDiscountPolicy ?? {},
|
||||||
rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy),
|
rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy),
|
||||||
quotaPolicyJson: stringifyJson(group.quotaPolicy),
|
quotaPolicyJson: stringifyJson(group.quotaPolicy),
|
||||||
metadataJson: stringifyJson(group.metadata),
|
metadataJson: stringifyJson(group.metadata),
|
||||||
@ -801,8 +807,8 @@ function formToUserGroupPayload(form: UserGroupForm): UserGroupUpsertRequest {
|
|||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
source: form.source,
|
source: form.source,
|
||||||
priority: Number(form.priority) || 100,
|
priority: Number(form.priority) || 100,
|
||||||
rechargeDiscountPolicy: parseJsonObject(form.rechargeDiscountPolicyJson, '充值折扣策略 JSON'),
|
rechargeDiscountPolicy: discountPolicyPayload(form.rechargeDiscountPolicy, form.rechargeDiscountFactor, '充值折扣系数'),
|
||||||
billingDiscountPolicy: parseJsonObject(form.billingDiscountPolicyJson, '计费折扣策略 JSON'),
|
billingDiscountPolicy: discountPolicyPayload(form.billingDiscountPolicy, form.billingDiscountFactor, '计费折扣系数'),
|
||||||
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
|
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
|
||||||
quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'),
|
quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'),
|
||||||
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
|
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
|
||||||
@ -854,14 +860,57 @@ function newIdempotencyKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function discountSummary(group: UserGroup) {
|
function discountSummary(group: UserGroup) {
|
||||||
const billing = group.billingDiscountPolicy?.discountFactor ?? group.billingDiscountPolicy?.factor;
|
const billing = discountFactorFromPolicy(group.billingDiscountPolicy);
|
||||||
const recharge = group.rechargeDiscountPolicy?.discountFactor ?? group.rechargeDiscountPolicy?.factor;
|
const recharge = discountFactorFromPolicy(group.rechargeDiscountPolicy);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (billing) parts.push(`计费 ${billing}`);
|
if (billing) parts.push(`计费 ${trimNumber(billing)}`);
|
||||||
if (recharge) parts.push(`充值 ${recharge}`);
|
if (recharge) parts.push(`充值 ${trimNumber(recharge)}`);
|
||||||
return parts.join(' / ') || '未设置';
|
return parts.join(' / ') || '未设置';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function discountFactorText(policy?: Record<string, unknown>) {
|
||||||
|
const value = discountFactorFromPolicy(policy);
|
||||||
|
return value ? trimNumber(value) : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function discountFactorFromPolicy(policy?: Record<string, unknown>) {
|
||||||
|
return numberFromUnknown(policy?.discountFactor) ?? numberFromUnknown(policy?.factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discountPolicyPayload(basePolicy: Record<string, unknown>, discountText: string, label: string) {
|
||||||
|
const policy = { ...basePolicy };
|
||||||
|
delete policy.discountFactor;
|
||||||
|
delete policy.factor;
|
||||||
|
const discount = optionalPositiveNumber(discountText, label);
|
||||||
|
if (discount && discount !== 1) {
|
||||||
|
policy.discountFactor = discount;
|
||||||
|
}
|
||||||
|
return Object.keys(policy).length ? policy : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPositiveNumber(value: string, label: string) {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return undefined;
|
||||||
|
const parsed = Number(text);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`${label} 必须是大于 0 的数字`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromUnknown(value: unknown) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimNumber(value: number) {
|
||||||
|
return value.toFixed(6).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function policyKeys(value?: Record<string, unknown>) {
|
function policyKeys(value?: Record<string, unknown>) {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
return Object.keys(value).slice(0, 3);
|
return Object.keys(value).slice(0, 3);
|
||||||
|
|||||||
444
apps/web/src/pages/admin/SystemSettingsPanel.tsx
Normal file
444
apps/web/src/pages/admin/SystemSettingsPanel.tsx
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Trash2 } from 'lucide-react';
|
||||||
|
import type { FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
|
||||||
|
import type { LoadState } from '../../types';
|
||||||
|
|
||||||
|
type SystemSettingsTab = 'fileStorage';
|
||||||
|
|
||||||
|
type FileStorageChannelForm = {
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyPreview: string;
|
||||||
|
channelKey: string;
|
||||||
|
configJson: string;
|
||||||
|
name: string;
|
||||||
|
priority: string;
|
||||||
|
provider: string;
|
||||||
|
retryPolicyJson: string;
|
||||||
|
scenes: string[];
|
||||||
|
status: string;
|
||||||
|
uploadUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultUploadUrl = 'http://127.0.0.1:3001/v1/files/upload';
|
||||||
|
const defaultRetryPolicy = {
|
||||||
|
enabled: true,
|
||||||
|
maxRetries: 3,
|
||||||
|
backoffSeconds: [60, 120, 180],
|
||||||
|
strategy: 'exponential',
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerOptions = [
|
||||||
|
{ value: 'server_main_openapi', label: 'server-main OpenAPI' },
|
||||||
|
{ value: 'aliyun_oss', label: '阿里云 OSS' },
|
||||||
|
{ value: 'tencent_cos', label: '腾讯云 COS' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultScenes = ['upload', 'image_result'];
|
||||||
|
const sceneOptions = [
|
||||||
|
{ value: 'upload', label: '上传', description: 'OpenAPI / 管理端主动上传文件' },
|
||||||
|
{ value: 'image_result', label: '返图', description: '模型返回 base64 / buffer 图片或视频后的转存' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const resultUploadPolicyOptions = [
|
||||||
|
{ value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存;base64 / buffer 等结果转存后保存 URL' },
|
||||||
|
{ value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等返图结果都会转存到当前文件渠道' },
|
||||||
|
{ value: 'upload_none', label: '全部不转存', description: '链接结果直接保存;base64 / buffer 结果写入网关本地静态托管后保存 URL' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SystemSettingsPanel(props: {
|
||||||
|
channels: FileStorageChannel[];
|
||||||
|
message: string;
|
||||||
|
settings: FileStorageSettings | null;
|
||||||
|
state: LoadState;
|
||||||
|
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
|
||||||
|
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||||
|
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<FileStorageChannel | null>(null);
|
||||||
|
const [pendingDeleteChannel, setPendingDeleteChannel] = useState<FileStorageChannel | null>(null);
|
||||||
|
const [form, setForm] = useState<FileStorageChannelForm>(() => defaultChannelForm());
|
||||||
|
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||||
|
const [localError, setLocalError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||||
|
}, [props.settings?.resultUploadPolicy]);
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditingChannel(null);
|
||||||
|
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
|
||||||
|
setLocalError('');
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editChannel(channel: FileStorageChannel) {
|
||||||
|
setEditingChannel(channel);
|
||||||
|
setForm(channelToForm(channel));
|
||||||
|
setLocalError('');
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
setEditingChannel(null);
|
||||||
|
setForm(defaultChannelForm());
|
||||||
|
setLocalError('');
|
||||||
|
setDialogOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLocalError('');
|
||||||
|
if (form.scenes.length === 0) {
|
||||||
|
setLocalError('请至少选择一个适用场景。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await props.onSaveFileStorageChannel(formToPayload(form), editingChannel?.id);
|
||||||
|
closeDialog();
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : '文件存储渠道保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChannel(channel: FileStorageChannel) {
|
||||||
|
try {
|
||||||
|
await props.onDeleteFileStorageChannel(channel.id);
|
||||||
|
setPendingDeleteChannel(null);
|
||||||
|
if (editingChannel?.id === channel.id) closeDialog();
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : '文件存储渠道删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
setLocalError('');
|
||||||
|
try {
|
||||||
|
await props.onSaveFileStorageSettings({ resultUploadPolicy: normalizeResultUploadPolicy(settingsPolicy) });
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : '文件存储全局策略保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pageStack">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div>
|
||||||
|
<CardTitle>系统设置</CardTitle>
|
||||||
|
<p className="mutedText">集中维护网关级配置;文件存储渠道按优先级轮转,单渠道使用 60/120/180 秒退避重试。</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{props.channels.length} 个文件渠道</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
tabs={[{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> }]}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{activeTab === 'fileStorage' && (
|
||||||
|
<section className="fileStoragePanel">
|
||||||
|
<div className="fileStorageSettingsCard">
|
||||||
|
<div>
|
||||||
|
<strong>全局返图转存策略</strong>
|
||||||
|
<span>对所有返图场景统一生效;渠道只负责上传目标、凭证、重试和轮转。</span>
|
||||||
|
</div>
|
||||||
|
<Label>
|
||||||
|
策略
|
||||||
|
<Select value={settingsPolicy} onChange={(event) => setSettingsPolicy(event.target.value)}>
|
||||||
|
{resultUploadPolicyOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
<small>{resultUploadPolicyDescription(settingsPolicy)}</small>
|
||||||
|
</Label>
|
||||||
|
<Button type="button" onClick={saveSettings} disabled={props.state === 'loading'}>
|
||||||
|
<Save size={15} />
|
||||||
|
保存策略
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fileStorageToolbar">
|
||||||
|
<div>
|
||||||
|
<strong>文件存储渠道</strong>
|
||||||
|
<span>server-main OpenAPI 渠道只需要上传路由和 API Key。</span>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={openCreateDialog}>
|
||||||
|
<Plus size={15} />
|
||||||
|
新增渠道
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fileStorageGrid">
|
||||||
|
{props.channels.map((channel) => (
|
||||||
|
<article className="fileStorageCard" key={channel.id}>
|
||||||
|
<header>
|
||||||
|
<div className="iconBox"><ServerCog size={18} /></div>
|
||||||
|
<div>
|
||||||
|
<strong>{channel.name}</strong>
|
||||||
|
<span>{channel.channelKey}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={channel.status === 'enabled' ? 'success' : 'secondary'}>{channel.status}</Badge>
|
||||||
|
</header>
|
||||||
|
<div className="fileStorageMeta">
|
||||||
|
<span>渠道: {providerLabel(channel.provider)}</span>
|
||||||
|
<span>场景: {sceneSummary(channel.scenes)}</span>
|
||||||
|
<span>优先级: {channel.priority}</span>
|
||||||
|
<span>重试: {retryPolicySummary(channel.retryPolicy)}</span>
|
||||||
|
{channel.uploadUrl && <span>上传路由: {channel.uploadUrl}</span>}
|
||||||
|
{apiKeyPreview(channel) && <span>API Key: {apiKeyPreview(channel)}</span>}
|
||||||
|
{channel.lastError && <span>最近错误: {channel.lastError}</span>}
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => editChannel(channel)}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
修改
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="destructive" size="sm" onClick={() => setPendingDeleteChannel(channel)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{!props.channels.length && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="emptyState">
|
||||||
|
<strong>暂无文件存储渠道</strong>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormDialog
|
||||||
|
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
|
||||||
|
bodyClassName="fileStorageDialogBody"
|
||||||
|
eyebrow={editingChannel ? 'Edit Storage Channel' : 'New Storage Channel'}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button type="submit" disabled={props.state === 'loading'}>
|
||||||
|
{editingChannel ? <Save size={15} /> : <Plus size={15} />}
|
||||||
|
{editingChannel ? '保存渠道' : '新增渠道'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||||
|
<RotateCcw size={15} />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onSubmit={submit}
|
||||||
|
>
|
||||||
|
<Label>
|
||||||
|
渠道标识
|
||||||
|
<Input value={form.channelKey} onChange={(event) => setForm({ ...form, channelKey: event.target.value })} placeholder="server-main-openapi" />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
渠道名称
|
||||||
|
<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="server-main OpenAPI" />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
渠道类型
|
||||||
|
<Select value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
|
||||||
|
{providerOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
状态
|
||||||
|
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
|
||||||
|
<option value="enabled">enabled</option>
|
||||||
|
<option value="disabled">disabled</option>
|
||||||
|
</Select>
|
||||||
|
</Label>
|
||||||
|
<Label className="spanTwo">
|
||||||
|
适用场景
|
||||||
|
<div className="fileStorageSceneGrid">
|
||||||
|
{sceneOptions.map((scene) => (
|
||||||
|
<FileStorageSceneToggle
|
||||||
|
checked={form.scenes.includes(scene.value)}
|
||||||
|
description={scene.description}
|
||||||
|
key={scene.value}
|
||||||
|
label={scene.label}
|
||||||
|
onChange={(checked) => setForm({ ...form, scenes: nextScenes(form.scenes, scene.value, checked) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Label className="spanTwo">
|
||||||
|
上传路由
|
||||||
|
<Input value={form.uploadUrl} onChange={(event) => setForm({ ...form, uploadUrl: event.target.value })} placeholder={defaultUploadUrl} />
|
||||||
|
</Label>
|
||||||
|
<Label className="platformCredentialField">
|
||||||
|
API Key
|
||||||
|
<Input value={form.apiKey} onChange={(event) => setForm({ ...form, apiKey: event.target.value })} placeholder={credentialInputPlaceholder(form.apiKeyPreview)} />
|
||||||
|
<small>保持脱敏值不变表示不修改;填写新值表示覆盖;清空后保存表示清除。</small>
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
优先级
|
||||||
|
<Input type="number" min={1} value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })} />
|
||||||
|
</Label>
|
||||||
|
<Label className="spanTwo">
|
||||||
|
重试策略 JSON
|
||||||
|
<Textarea value={form.retryPolicyJson} onChange={(event) => setForm({ ...form, retryPolicyJson: event.target.value })} />
|
||||||
|
</Label>
|
||||||
|
<Label className="spanTwo">
|
||||||
|
扩展配置 JSON
|
||||||
|
<Textarea value={form.configJson} onChange={(event) => setForm({ ...form, configJson: event.target.value })} />
|
||||||
|
</Label>
|
||||||
|
</FormDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
confirmLabel="删除渠道"
|
||||||
|
description="删除后该文件存储渠道不会再参与上传轮转。"
|
||||||
|
loading={props.state === 'loading'}
|
||||||
|
open={Boolean(pendingDeleteChannel)}
|
||||||
|
title={`确认删除文件存储渠道 ${pendingDeleteChannel?.name ?? ''}?`}
|
||||||
|
onCancel={() => setPendingDeleteChannel(null)}
|
||||||
|
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
|
||||||
|
return {
|
||||||
|
apiKey: '',
|
||||||
|
apiKeyPreview: '',
|
||||||
|
channelKey,
|
||||||
|
configJson: '{}',
|
||||||
|
name: 'server-main OpenAPI',
|
||||||
|
priority: '100',
|
||||||
|
provider: 'server_main_openapi',
|
||||||
|
retryPolicyJson: stringifyJson(defaultRetryPolicy),
|
||||||
|
scenes: defaultScenes,
|
||||||
|
status: 'disabled',
|
||||||
|
uploadUrl: defaultUploadUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
|
||||||
|
const preview = apiKeyPreview(channel);
|
||||||
|
return {
|
||||||
|
apiKey: preview,
|
||||||
|
apiKeyPreview: preview,
|
||||||
|
channelKey: channel.channelKey,
|
||||||
|
configJson: stringifyJson(channel.config ?? {}),
|
||||||
|
name: channel.name,
|
||||||
|
priority: String(channel.priority || 100),
|
||||||
|
provider: channel.provider || 'server_main_openapi',
|
||||||
|
retryPolicyJson: stringifyJson(channel.retryPolicy ?? defaultRetryPolicy),
|
||||||
|
scenes: normalizeScenes(channel.scenes),
|
||||||
|
status: channel.status || 'disabled',
|
||||||
|
uploadUrl: channel.uploadUrl || defaultUploadUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRequest {
|
||||||
|
return {
|
||||||
|
apiKey: apiKeyPayloadValue(form),
|
||||||
|
channelKey: form.channelKey.trim(),
|
||||||
|
config: parseJsonObject(form.configJson, '扩展配置 JSON'),
|
||||||
|
name: form.name.trim(),
|
||||||
|
priority: Number(form.priority) || 100,
|
||||||
|
provider: form.provider,
|
||||||
|
retryPolicy: parseJsonObject(form.retryPolicyJson, '重试策略 JSON'),
|
||||||
|
scenes: normalizeScenes(form.scenes),
|
||||||
|
status: form.status,
|
||||||
|
uploadUrl: form.uploadUrl.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(value: string, label: string) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value || '{}') as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${label} 必须是对象`);
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes(label)) throw err;
|
||||||
|
throw new Error(`${label} 格式不正确`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyJson(value: unknown) {
|
||||||
|
return JSON.stringify(value ?? {}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(provider: string) {
|
||||||
|
return providerOptions.find((item) => item.value === provider)?.label ?? provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneSummary(scenes: string[] | undefined) {
|
||||||
|
return normalizeScenes(scenes).map((scene) => sceneOptions.find((item) => item.value === scene)?.label ?? scene).join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResultUploadPolicy(value: string | undefined) {
|
||||||
|
const normalized = (value || 'default').trim();
|
||||||
|
return resultUploadPolicyOptions.some((item) => item.value === normalized) ? normalized : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultUploadPolicyDescription(value: string | undefined) {
|
||||||
|
const normalized = normalizeResultUploadPolicy(value);
|
||||||
|
return resultUploadPolicyOptions.find((item) => item.value === normalized)?.description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScenes(scenes: string[] | undefined) {
|
||||||
|
const next = Array.from(new Set((scenes ?? []).map((scene) => scene.trim()).filter(Boolean)));
|
||||||
|
return next.length ? next : [...defaultScenes];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextScenes(current: string[], scene: string, checked: boolean) {
|
||||||
|
if (checked) return normalizeScenes([...current, scene]);
|
||||||
|
return current.filter((item) => item !== scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryPolicySummary(policy?: Record<string, unknown>) {
|
||||||
|
const maxRetries = numberFromUnknown(policy?.maxRetries) || 3;
|
||||||
|
const backoff = Array.isArray(policy?.backoffSeconds) ? policy?.backoffSeconds.join('/') : '60/120/180';
|
||||||
|
return `${maxRetries} 次 · ${backoff}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiKeyPreview(channel: FileStorageChannel) {
|
||||||
|
const value = channel.credentialsPreview?.apiKey;
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiKeyPayloadValue(form: FileStorageChannelForm) {
|
||||||
|
const value = form.apiKey.trim();
|
||||||
|
if (form.apiKeyPreview && value === form.apiKeyPreview) return undefined;
|
||||||
|
return value || (form.apiKeyPreview ? '' : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialInputPlaceholder(preview: string) {
|
||||||
|
return preview ? '填写新凭证以覆盖当前值' : 'sk-...';
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileStorageSceneToggle(props: { checked: boolean; description: string; label: string; onChange: (checked: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="platformToggle">
|
||||||
|
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
|
||||||
|
<span>
|
||||||
|
<strong>{props.label}</strong>
|
||||||
|
<small>{props.description}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromUnknown(value: unknown) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
685
apps/web/src/pages/playground-chat.tsx
Normal file
685
apps/web/src/pages/playground-chat.tsx
Normal file
@ -0,0 +1,685 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AssistantRuntimeProvider,
|
||||||
|
ComposerPrimitive,
|
||||||
|
ErrorPrimitive,
|
||||||
|
MessagePrimitive,
|
||||||
|
ThreadPrimitive,
|
||||||
|
useLocalRuntime,
|
||||||
|
useMessage,
|
||||||
|
useMessagePartText,
|
||||||
|
useThread,
|
||||||
|
type ChatModelAdapter,
|
||||||
|
type ThreadMessage,
|
||||||
|
type ThreadMessageLike,
|
||||||
|
} from '@assistant-ui/react';
|
||||||
|
import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown';
|
||||||
|
import { cjk } from '@streamdown/cjk';
|
||||||
|
import { code } from '@streamdown/code';
|
||||||
|
import { math } from '@streamdown/math';
|
||||||
|
import { mermaid } from '@streamdown/mermaid';
|
||||||
|
import type { GatewayApiKey } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import { Button, Select } from '../components/ui';
|
||||||
|
import { GatewayApiError, streamChatCompletionText } from '../api';
|
||||||
|
import type { PlaygroundMode } from '../types';
|
||||||
|
import {
|
||||||
|
chatUploadAccept as sharedChatUploadAccept,
|
||||||
|
mediaUploadSummaryMessage as sharedMediaUploadSummaryMessage,
|
||||||
|
openAIContentFromPromptAndUploads,
|
||||||
|
PlaygroundReferencePicker,
|
||||||
|
uploadPlaygroundFiles as sharedUploadPlaygroundFiles,
|
||||||
|
type OpenAIChatContentPart,
|
||||||
|
type PlaygroundUpload,
|
||||||
|
} from './playground-upload';
|
||||||
|
import {
|
||||||
|
ModeSwitch,
|
||||||
|
PlaygroundGreeting,
|
||||||
|
apiKeyNoticeText,
|
||||||
|
modeOptions,
|
||||||
|
modelOptionLabel,
|
||||||
|
placeholderByMode,
|
||||||
|
resolveSelectedApiKeyId,
|
||||||
|
type ModelOption,
|
||||||
|
} from './playground-shared';
|
||||||
|
|
||||||
|
const CHAT_MESSAGES_STORAGE_KEY = 'easyai:playground:chat-messages:v1';
|
||||||
|
const CHAT_MESSAGES_STORAGE_LIMIT = 100;
|
||||||
|
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||||
|
|
||||||
|
type OpenAIChatRole = 'assistant' | 'user';
|
||||||
|
|
||||||
|
interface StoredOpenAIChatMessage {
|
||||||
|
content: OpenAIChatContentPart[] | string;
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
role: OpenAIChatRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredOpenAIChatMessagesById = Record<string, StoredOpenAIChatMessage>;
|
||||||
|
|
||||||
|
export function AssistantChatPlayground(props: {
|
||||||
|
apiKeySecretsById: Record<string, string>;
|
||||||
|
apiKeys: GatewayApiKey[];
|
||||||
|
modelOptions: ModelOption[];
|
||||||
|
selectedApiKeyId: string;
|
||||||
|
selectedModel: string;
|
||||||
|
token: string;
|
||||||
|
onApiKeyChange: (apiKeyId: string) => void;
|
||||||
|
onCreateApiKey: () => void;
|
||||||
|
onLogin: () => void;
|
||||||
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
|
onModelChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
||||||
|
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
|
||||||
|
const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
|
||||||
|
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
||||||
|
const initialStoredMessages = useMemo(() => readStoredOpenAIChatMessages(), []);
|
||||||
|
const initialMessages = useMemo(() => initialStoredMessages.map(threadMessageLikeFromOpenAIMessage), [initialStoredMessages]);
|
||||||
|
const [storedMessagesById, setStoredMessagesById] = useState<StoredOpenAIChatMessagesById>(() => indexStoredOpenAIChatMessages(initialStoredMessages));
|
||||||
|
const [chatUploadMessage, setChatUploadMessage] = useState('');
|
||||||
|
const [chatUploads, setChatUploads] = useState<PlaygroundUpload[]>([]);
|
||||||
|
const [chatUploading, setChatUploading] = useState(false);
|
||||||
|
const chatUploadsRef = useRef(chatUploads);
|
||||||
|
const storedMessagesByIdRef = useRef(storedMessagesById);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatUploadsRef.current = chatUploads;
|
||||||
|
}, [chatUploads]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storedMessagesByIdRef.current = storedMessagesById;
|
||||||
|
}, [storedMessagesById]);
|
||||||
|
|
||||||
|
async function uploadChatFiles(files: File[]) {
|
||||||
|
if (!files.length) return;
|
||||||
|
if (!props.token) {
|
||||||
|
props.onLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!activeApiKeySecret) {
|
||||||
|
setChatUploadMessage('请选择可用于测试的 API Key 后再上传。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChatUploading(true);
|
||||||
|
setChatUploadMessage('');
|
||||||
|
try {
|
||||||
|
const { items, warnings } = await sharedUploadPlaygroundFiles(activeApiKeySecret, files, {
|
||||||
|
allowFiles: true,
|
||||||
|
source: 'ai-gateway-playground-chat',
|
||||||
|
});
|
||||||
|
if (items.length) {
|
||||||
|
setChatUploads((current) => [...current, ...items]);
|
||||||
|
}
|
||||||
|
setChatUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个附件。` : ''));
|
||||||
|
} catch (err) {
|
||||||
|
setChatUploadMessage(err instanceof Error ? err.message : '文件上传失败');
|
||||||
|
} finally {
|
||||||
|
setChatUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = useMemo<ChatModelAdapter>(() => ({
|
||||||
|
async *run({ abortSignal, messages }) {
|
||||||
|
if (!props.token) {
|
||||||
|
props.onLogin();
|
||||||
|
throw new GatewayApiError('请先登录后再测试模型。');
|
||||||
|
}
|
||||||
|
if (!activeApiKeySecret) {
|
||||||
|
throw new GatewayApiError('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。');
|
||||||
|
}
|
||||||
|
if (!props.selectedModel) {
|
||||||
|
throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
|
||||||
|
}
|
||||||
|
const requestUploads = chatUploadsRef.current;
|
||||||
|
const request = buildGatewayChatMessages(messages, requestUploads, storedMessagesByIdRef.current);
|
||||||
|
if (request.lastUserMessage) {
|
||||||
|
setStoredMessagesById((current) => ({
|
||||||
|
...current,
|
||||||
|
[request.lastUserMessage!.id]: request.lastUserMessage!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (requestUploads.length) {
|
||||||
|
chatUploadsRef.current = [];
|
||||||
|
setChatUploads([]);
|
||||||
|
setChatUploadMessage('');
|
||||||
|
}
|
||||||
|
let text = '';
|
||||||
|
for await (const delta of streamChatCompletionText(
|
||||||
|
activeApiKeySecret,
|
||||||
|
{
|
||||||
|
messages: request.messages,
|
||||||
|
model: props.selectedModel,
|
||||||
|
},
|
||||||
|
abortSignal,
|
||||||
|
)) {
|
||||||
|
text += delta;
|
||||||
|
yield {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
status: { type: 'complete', reason: 'stop' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}), [activeApiKeySecret, props]);
|
||||||
|
const runtime = useLocalRuntime(adapter, { initialMessages });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<AssistantChatPersistenceBridge storedMessagesById={storedMessagesById} />
|
||||||
|
<ThreadPrimitive.Root className="assistantThreadRoot">
|
||||||
|
<ThreadPrimitive.Empty>
|
||||||
|
<div className="assistantEmptyStage">
|
||||||
|
<AssistantEmptyState
|
||||||
|
canRun={canRun}
|
||||||
|
modelOptions={props.modelOptions}
|
||||||
|
selectedModel={props.selectedModel}
|
||||||
|
token={props.token}
|
||||||
|
activeApiKeySecret={activeApiKeySecret}
|
||||||
|
uploadAccept={sharedChatUploadAccept}
|
||||||
|
uploadMessage={chatUploadMessage}
|
||||||
|
uploads={chatUploads}
|
||||||
|
uploading={chatUploading}
|
||||||
|
onModeChange={props.onModeChange}
|
||||||
|
onModelChange={props.onModelChange}
|
||||||
|
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
|
||||||
|
onUploadFiles={(files) => void uploadChatFiles(files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ThreadPrimitive.Empty>
|
||||||
|
<ThreadPrimitive.If empty={false}>
|
||||||
|
<div className="assistantShell" data-has-notice={Boolean(apiKeyNotice)}>
|
||||||
|
{apiKeyNotice && (
|
||||||
|
<div className="assistantApiKeyNotice">
|
||||||
|
<span>{apiKeyNotice}</span>
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
|
||||||
|
去创建 API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ThreadPrimitive.Viewport className="assistantThreadViewport">
|
||||||
|
<div className="assistantMessageList">
|
||||||
|
<ThreadPrimitive.Messages
|
||||||
|
components={{
|
||||||
|
Message: () => (
|
||||||
|
<AssistantMessage storedMessagesById={storedMessagesById} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ThreadPrimitive.ViewportFooter className="assistantComposerDock">
|
||||||
|
<AssistantChatComposer
|
||||||
|
canRun={canRun}
|
||||||
|
docked
|
||||||
|
modelOptions={props.modelOptions}
|
||||||
|
placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
|
||||||
|
selectedModel={props.selectedModel}
|
||||||
|
uploadAccept={sharedChatUploadAccept}
|
||||||
|
uploadMessage={chatUploadMessage}
|
||||||
|
uploads={chatUploads}
|
||||||
|
uploading={chatUploading}
|
||||||
|
onModeChange={props.onModeChange}
|
||||||
|
onModelChange={props.onModelChange}
|
||||||
|
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
|
||||||
|
onUploadFiles={(files) => void uploadChatFiles(files)}
|
||||||
|
/>
|
||||||
|
</ThreadPrimitive.ViewportFooter>
|
||||||
|
</ThreadPrimitive.Viewport>
|
||||||
|
</div>
|
||||||
|
</ThreadPrimitive.If>
|
||||||
|
</ThreadPrimitive.Root>
|
||||||
|
</AssistantRuntimeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantChatPersistenceBridge(props: { storedMessagesById: StoredOpenAIChatMessagesById }) {
|
||||||
|
const messages = useThread((state) => state.messages);
|
||||||
|
const skipInitialEmptyWriteRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipInitialEmptyWriteRef.current) {
|
||||||
|
skipInitialEmptyWriteRef.current = false;
|
||||||
|
if (!messages.length && hasStoredChatMessages()) return;
|
||||||
|
}
|
||||||
|
writeStoredOpenAIChatMessages(messages, props.storedMessagesById);
|
||||||
|
}, [messages, props.storedMessagesById]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantEmptyState(props: {
|
||||||
|
activeApiKeySecret: string;
|
||||||
|
canRun: boolean;
|
||||||
|
modelOptions: ModelOption[];
|
||||||
|
selectedModel: string;
|
||||||
|
token: string;
|
||||||
|
uploadAccept: string;
|
||||||
|
uploadMessage: string;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading: boolean;
|
||||||
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
|
onModelChange: (value: string) => void;
|
||||||
|
onRemoveUpload: (id: string) => void;
|
||||||
|
onUploadFiles: (files: File[]) => void;
|
||||||
|
}) {
|
||||||
|
const activeMode = modeOptions.find((item) => item.value === 'chat') ?? modeOptions[0];
|
||||||
|
const placeholder = props.canRun ? placeholderByMode.chat : assistantPlaceholder(props.token, props.selectedModel, props.activeApiKeySecret);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="assistantEmpty">
|
||||||
|
<ModeSwitch activeMode="chat" onModeChange={props.onModeChange} />
|
||||||
|
<PlaygroundGreeting activeMode={activeMode} />
|
||||||
|
<AssistantChatComposer
|
||||||
|
canRun={props.canRun}
|
||||||
|
modelOptions={props.modelOptions}
|
||||||
|
placeholder={placeholder}
|
||||||
|
selectedModel={props.selectedModel}
|
||||||
|
uploadAccept={props.uploadAccept}
|
||||||
|
uploadMessage={props.uploadMessage}
|
||||||
|
uploads={props.uploads}
|
||||||
|
uploading={props.uploading}
|
||||||
|
onModeChange={props.onModeChange}
|
||||||
|
onModelChange={props.onModelChange}
|
||||||
|
onRemoveUpload={props.onRemoveUpload}
|
||||||
|
onUploadFiles={props.onUploadFiles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantChatComposer(props: {
|
||||||
|
canRun: boolean;
|
||||||
|
docked?: boolean;
|
||||||
|
modelOptions: ModelOption[];
|
||||||
|
placeholder: string;
|
||||||
|
selectedModel: string;
|
||||||
|
uploadAccept?: string;
|
||||||
|
uploadMessage?: string;
|
||||||
|
uploads?: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
|
onModelChange: (value: string) => void;
|
||||||
|
onRemoveUpload?: (id: string) => void;
|
||||||
|
onUploadFiles?: (files: File[]) => void;
|
||||||
|
}) {
|
||||||
|
const className = ['playgroundComposer', 'assistantChatComposer', props.docked ? 'assistantDockComposer' : 'assistantEmptyComposer'].join(' ');
|
||||||
|
const uploadMessage = props.uploadMessage || sharedMediaUploadSummaryMessage(props.uploads ?? [], 'chat', 'text_to_video');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerPrimitive.Root className={className}>
|
||||||
|
<div className="composerBody composerBodyWithReferences">
|
||||||
|
<PlaygroundReferencePicker
|
||||||
|
accept={props.uploadAccept ?? sharedChatUploadAccept}
|
||||||
|
disabled={!props.canRun || !props.onUploadFiles}
|
||||||
|
mode="chat"
|
||||||
|
uploadLabel="上传附件"
|
||||||
|
uploads={props.uploads ?? []}
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onUploadFiles}
|
||||||
|
onRemove={props.onRemoveUpload}
|
||||||
|
/>
|
||||||
|
<div className="composerInputStack">
|
||||||
|
<ComposerPrimitive.Input
|
||||||
|
className="assistantEmptyInput"
|
||||||
|
disabled={!props.canRun}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
/>
|
||||||
|
{uploadMessage && <div className="composerUploadMessage">{uploadMessage}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="composerFooter">
|
||||||
|
<Select value="chat" onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
|
||||||
|
{modeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
className="playgroundModelSelect"
|
||||||
|
value={props.selectedModel}
|
||||||
|
disabled={!props.modelOptions.length}
|
||||||
|
onChange={(event) => props.onModelChange(event.target.value)}
|
||||||
|
>
|
||||||
|
{props.modelOptions.length ? props.modelOptions.map((item) => (
|
||||||
|
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
|
||||||
|
)) : <option value="">没有可用模型</option>}
|
||||||
|
</Select>
|
||||||
|
<ComposerPrimitive.Send className="composerSendButton" disabled={!props.canRun} aria-label="发送消息">
|
||||||
|
<Send size={18} />
|
||||||
|
</ComposerPrimitive.Send>
|
||||||
|
</div>
|
||||||
|
</ComposerPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantMessage(props: { storedMessagesById: StoredOpenAIChatMessagesById }) {
|
||||||
|
const messageId = useMessage((state) => state.id);
|
||||||
|
const messageContent = useMessage((state) => state.content);
|
||||||
|
const hasError = useMessage((state) => state.status?.type === 'incomplete' && state.status.reason === 'error');
|
||||||
|
const storedMessage = messageId ? props.storedMessagesById[messageId] : undefined;
|
||||||
|
const imageParts = imagePartsFromOpenAIContent(storedMessage?.content);
|
||||||
|
const hasText = threadMessageContentText(messageContent).trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessagePrimitive.Root className="assistantMessage">
|
||||||
|
<MessagePrimitive.If user>
|
||||||
|
<div className="assistantUserMessage">
|
||||||
|
<ChatMessageImagePreviews parts={imageParts} />
|
||||||
|
{hasText && (
|
||||||
|
<div className="assistantBubble user">
|
||||||
|
<MessagePrimitive.Parts components={{ Text: PlainMessageText }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MessagePrimitive.If>
|
||||||
|
<MessagePrimitive.If assistant>
|
||||||
|
<div className={hasError ? 'assistantBubble assistant error' : 'assistantBubble assistant'}>
|
||||||
|
<MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
|
||||||
|
<MessagePrimitive.Error>
|
||||||
|
<strong>调用失败</strong>
|
||||||
|
<ErrorPrimitive.Message className="assistantErrorMessage" />
|
||||||
|
</MessagePrimitive.Error>
|
||||||
|
{!hasError && (
|
||||||
|
<MessagePrimitive.If hasContent={false}>
|
||||||
|
<span className="assistantTyping">模型正在回复...</span>
|
||||||
|
</MessagePrimitive.If>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MessagePrimitive.If>
|
||||||
|
</MessagePrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatMessageImagePreviews(props: { parts: Array<{ name: string; url: string }> }) {
|
||||||
|
if (!props.parts.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="assistantUserImageGrid">
|
||||||
|
{props.parts.map((item) => (
|
||||||
|
<a className="assistantUserImagePreview" href={item.url} key={item.url} rel="noreferrer" target="_blank" title={item.name}>
|
||||||
|
<img src={item.url} alt={item.name} loading="lazy" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlainMessageText() {
|
||||||
|
const { text } = useMessagePartText();
|
||||||
|
return <span className="assistantPlainText">{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantMarkdownText() {
|
||||||
|
return (
|
||||||
|
<StreamdownTextPrimitive
|
||||||
|
containerClassName="assistantMarkdown"
|
||||||
|
plugins={streamdownPlugins}
|
||||||
|
shikiTheme={['github-light', 'github-dark']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayChatMessageForRequest extends Record<string, unknown> {
|
||||||
|
content: OpenAIChatContentPart[] | string;
|
||||||
|
role: OpenAIChatRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGatewayChatMessages(
|
||||||
|
messages: readonly ThreadMessage[],
|
||||||
|
uploads: PlaygroundUpload[],
|
||||||
|
storedMessagesById: StoredOpenAIChatMessagesById,
|
||||||
|
) {
|
||||||
|
const sourceMessages = messages.filter((message) => message.role === 'user' || message.role === 'assistant');
|
||||||
|
let sourceLastUserIndex = -1;
|
||||||
|
sourceMessages.forEach((message, index) => {
|
||||||
|
if (message.role === 'user') sourceLastUserIndex = index;
|
||||||
|
});
|
||||||
|
const gatewayMessages: GatewayChatMessageForRequest[] = [];
|
||||||
|
let lastUserMessage: StoredOpenAIChatMessage | undefined;
|
||||||
|
sourceMessages.forEach((message, index) => {
|
||||||
|
const isUploadTarget = uploads.length > 0 && index === sourceLastUserIndex && message.role === 'user';
|
||||||
|
const text = threadMessageText(message);
|
||||||
|
const preserved = storedMessagesById[message.id];
|
||||||
|
const content = isUploadTarget
|
||||||
|
? openAIContentFromPromptAndUploads(text, uploads)
|
||||||
|
: preserved?.content ?? text;
|
||||||
|
if (!openAIContentHasPayload(content)) return;
|
||||||
|
gatewayMessages.push({
|
||||||
|
content,
|
||||||
|
role: message.role,
|
||||||
|
});
|
||||||
|
if (isUploadTarget) {
|
||||||
|
lastUserMessage = {
|
||||||
|
content,
|
||||||
|
createdAt: message.createdAt.toISOString(),
|
||||||
|
id: message.id,
|
||||||
|
role: 'user',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { lastUserMessage, messages: gatewayMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredOpenAIChatMessages(): StoredOpenAIChatMessage[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
const record = recordFromUnknown(parsed);
|
||||||
|
const source = Array.isArray(parsed) ? parsed : record?.messages;
|
||||||
|
if (!Array.isArray(source)) return [];
|
||||||
|
const legacyImagesByMessageId = recordFromUnknown(record?.imageUploadsByMessageId);
|
||||||
|
return source
|
||||||
|
.map((item) => storedOpenAIChatMessageFromStorage(item, legacyImagesByMessageId))
|
||||||
|
.filter((item): item is StoredOpenAIChatMessage => Boolean(item))
|
||||||
|
.slice(-CHAT_MESSAGES_STORAGE_LIMIT);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredOpenAIChatMessages(messages: readonly ThreadMessage[], preservedMessagesById: StoredOpenAIChatMessagesById) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const storedMessages = messages
|
||||||
|
.map((message) => storedOpenAIChatMessageFromThread(message, preservedMessagesById[message.id]))
|
||||||
|
.filter((item): item is StoredOpenAIChatMessage => Boolean(item))
|
||||||
|
.slice(-CHAT_MESSAGES_STORAGE_LIMIT);
|
||||||
|
if (!storedMessages.length) {
|
||||||
|
window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(CHAT_MESSAGES_STORAGE_KEY, JSON.stringify({
|
||||||
|
messages: storedMessages,
|
||||||
|
version: 2,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Best effort only: local chat history should not block sending messages.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredChatMessages() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStoredChatMessages() {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return Boolean(window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedOpenAIChatMessageFromThread(message: ThreadMessage, preserved?: StoredOpenAIChatMessage): StoredOpenAIChatMessage | undefined {
|
||||||
|
if (message.role !== 'assistant' && message.role !== 'user') return undefined;
|
||||||
|
const content = message.role === 'user' && preserved
|
||||||
|
? preserved.content
|
||||||
|
: threadMessageText(message);
|
||||||
|
if (!openAIContentHasPayload(content)) return undefined;
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
createdAt: message.createdAt.toISOString(),
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedOpenAIChatMessageFromStorage(value: unknown, legacyImagesByMessageId?: Record<string, unknown>): StoredOpenAIChatMessage | undefined {
|
||||||
|
const record = recordFromUnknown(value);
|
||||||
|
if (!record) return undefined;
|
||||||
|
const role = record.role === 'assistant' || record.role === 'user' ? record.role : undefined;
|
||||||
|
const id = stringFromUnknown(record.id);
|
||||||
|
if (!role || !id) return undefined;
|
||||||
|
const createdAt = dateStringFromUnknown(record.createdAt) ?? new Date().toISOString();
|
||||||
|
let content = openAIContentFromUnknown(record.content);
|
||||||
|
const legacyImages = Array.isArray(legacyImagesByMessageId?.[id]) ? legacyImagesByMessageId[id] as unknown[] : [];
|
||||||
|
if (role === 'user' && legacyImages.length && (typeof content === 'string' || !content)) {
|
||||||
|
content = openAIContentWithLegacyImages(typeof content === 'string' ? content : '', legacyImages);
|
||||||
|
}
|
||||||
|
if (!content || !openAIContentHasPayload(content)) return undefined;
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadMessageLikeFromOpenAIMessage(message: StoredOpenAIChatMessage): ThreadMessageLike {
|
||||||
|
return {
|
||||||
|
content: threadMessageLikeContentFromOpenAIContent(message.content),
|
||||||
|
createdAt: new Date(message.createdAt),
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
status: message.role === 'assistant' ? { type: 'complete', reason: 'stop' } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexStoredOpenAIChatMessages(messages: StoredOpenAIChatMessage[]) {
|
||||||
|
return messages.reduce<StoredOpenAIChatMessagesById>((result, message) => {
|
||||||
|
result[message.id] = message;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadMessageLikeContentFromOpenAIContent(content: OpenAIChatContentPart[] | string): ThreadMessageLike['content'] {
|
||||||
|
const text = openAIContentText(content);
|
||||||
|
return text || ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadMessageText(message: ThreadMessage) {
|
||||||
|
return threadMessageContentText(message.content).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadMessageContentText(content: ThreadMessage['content']) {
|
||||||
|
return content
|
||||||
|
.map((part) => part.type === 'text' ? part.text : '')
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAIContentFromUnknown(value: unknown): OpenAIChatContentPart[] | string | undefined {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const parts = value
|
||||||
|
.map(openAIContentPartFromUnknown)
|
||||||
|
.filter((item): item is OpenAIChatContentPart => Boolean(item));
|
||||||
|
return parts.length ? parts : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAIContentPartFromUnknown(value: unknown): OpenAIChatContentPart | undefined {
|
||||||
|
const record = recordFromUnknown(value);
|
||||||
|
if (!record) return undefined;
|
||||||
|
if (record.type === 'text' && typeof record.text === 'string') {
|
||||||
|
return { type: 'text', text: record.text };
|
||||||
|
}
|
||||||
|
if (record.type === 'image_url') {
|
||||||
|
const imageUrl = recordFromUnknown(record.image_url);
|
||||||
|
const url = stringFromUnknown(imageUrl?.url);
|
||||||
|
return url ? { type: 'image_url', image_url: { url } } : undefined;
|
||||||
|
}
|
||||||
|
if (record.type === 'video_url') {
|
||||||
|
const videoUrl = recordFromUnknown(record.video_url);
|
||||||
|
const url = stringFromUnknown(videoUrl?.url);
|
||||||
|
return url ? { type: 'video_url', video_url: { url } } : undefined;
|
||||||
|
}
|
||||||
|
if (record.type === 'audio_url') {
|
||||||
|
const audioUrl = recordFromUnknown(record.audio_url);
|
||||||
|
const url = stringFromUnknown(audioUrl?.url);
|
||||||
|
return url ? { type: 'audio_url', audio_url: { url } } : undefined;
|
||||||
|
}
|
||||||
|
if (record.type === 'file_url') {
|
||||||
|
const fileUrl = recordFromUnknown(record.file_url);
|
||||||
|
const url = stringFromUnknown(fileUrl?.url);
|
||||||
|
const filename = stringFromUnknown(fileUrl?.filename) || '文件';
|
||||||
|
return url ? { type: 'file_url', file_url: { filename, url } } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAIContentWithLegacyImages(text: string, images: unknown[]): OpenAIChatContentPart[] {
|
||||||
|
const content: OpenAIChatContentPart[] = [];
|
||||||
|
if (text.trim()) {
|
||||||
|
content.push({ type: 'text', text });
|
||||||
|
}
|
||||||
|
images.forEach((item) => {
|
||||||
|
const record = recordFromUnknown(item);
|
||||||
|
const url = stringFromUnknown(record?.url);
|
||||||
|
if (url) {
|
||||||
|
content.push({ type: 'image_url', image_url: { url } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAIContentHasPayload(content: OpenAIChatContentPart[] | string) {
|
||||||
|
if (typeof content === 'string') return content.trim().length > 0;
|
||||||
|
return content.some((part) => {
|
||||||
|
if (part.type === 'text') return part.text.trim().length > 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAIContentText(content: OpenAIChatContentPart[] | string) {
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
return content
|
||||||
|
.map((part) => part.type === 'text' ? part.text : '')
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function imagePartsFromOpenAIContent(content: OpenAIChatContentPart[] | string | undefined) {
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
return content.flatMap((part) => {
|
||||||
|
if (part.type !== 'image_url') return [];
|
||||||
|
return [{ name: '图片', url: part.image_url.url }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantPlaceholder(token: string, selectedModel: string, apiKeySecret: string) {
|
||||||
|
if (!token) return '请先登录后再测试模型';
|
||||||
|
if (!apiKeySecret) return '请选择可用于测试的 API Key';
|
||||||
|
if (!selectedModel) return '当前没有可用模型';
|
||||||
|
return '输入消息,Enter 发送,Shift + Enter 换行';
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringFromUnknown(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateStringFromUnknown(value: unknown) {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
@ -5,16 +5,19 @@ import Slider from 'antd/es/slider';
|
|||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Edit3,
|
Edit3,
|
||||||
|
FileText,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Images,
|
Images,
|
||||||
Link2,
|
Link2,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
|
Music2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { resolveApiAssetUrl } from '../api';
|
import { resolveApiAssetUrl } from '../api';
|
||||||
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '../components/ui';
|
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '../components/ui';
|
||||||
import type { PlaygroundMode } from '../types';
|
import type { PlaygroundMode } from '../types';
|
||||||
|
import type { PlaygroundUpload, PlaygroundUploadKind, PlaygroundVideoCreateMode } from './playground-upload';
|
||||||
|
|
||||||
export type MediaOutputMode = 'single' | 'group';
|
export type MediaOutputMode = 'single' | 'group';
|
||||||
export type MediaCountPreset = 1 | 2 | 3 | 4 | 'custom';
|
export type MediaCountPreset = 1 | 2 | 3 | 4 | 'custom';
|
||||||
@ -46,6 +49,8 @@ export interface MediaGenerationRun {
|
|||||||
settings: MediaGenerationSettings;
|
settings: MediaGenerationSettings;
|
||||||
status: GatewayTask['status'] | 'submitting';
|
status: GatewayTask['status'] | 'submitting';
|
||||||
task?: GatewayTask;
|
task?: GatewayTask;
|
||||||
|
uploads?: PlaygroundUpload[];
|
||||||
|
videoMode?: PlaygroundVideoCreateMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function gatewayTaskErrorText(task: GatewayTask | undefined, fallback = '任务失败') {
|
export function gatewayTaskErrorText(task: GatewayTask | undefined, fallback = '任务失败') {
|
||||||
@ -159,8 +164,6 @@ export function mediaRequestPayload(settings: MediaGenerationSettings, mode: Exc
|
|||||||
aspect_ratio: settings.aspectRatio === 'auto' ? undefined : settings.aspectRatio,
|
aspect_ratio: settings.aspectRatio === 'auto' ? undefined : settings.aspectRatio,
|
||||||
audio: settings.outputAudio,
|
audio: settings.outputAudio,
|
||||||
duration: settings.durationSeconds,
|
duration: settings.durationSeconds,
|
||||||
duration_seconds: settings.durationSeconds,
|
|
||||||
output_audio: settings.outputAudio,
|
|
||||||
resolution: settings.resolution,
|
resolution: settings.resolution,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -515,23 +518,44 @@ function MediaTaskCard(props: {
|
|||||||
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
|
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
|
||||||
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
|
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
|
||||||
const errorText = mediaRunErrorText(props.run);
|
const errorText = mediaRunErrorText(props.run);
|
||||||
|
const references = mediaReferenceItems(props.run);
|
||||||
|
const promptParts = promptDisplayParts(props.run.prompt, references);
|
||||||
|
const taskMeta = mediaTaskMetaText(props.run);
|
||||||
|
const previewState = items.length > 0 ? 'filled' : isPending ? 'loading' : 'empty';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="mediaTaskItem" data-status={props.run.status}>
|
<article className="mediaTaskItem" data-status={props.run.status}>
|
||||||
<header className="mediaTaskHeader">
|
<header className="mediaTaskHeader">
|
||||||
<div>
|
<div className="mediaTaskHeaderMain">
|
||||||
<p>
|
{references.length > 0 && <MediaTaskReferenceStack references={references} />}
|
||||||
<span>{props.run.prompt}</span>
|
<div className="mediaTaskHeaderContent">
|
||||||
<small>{props.run.mode === 'video' ? '视频' : '图片'} {props.run.modelLabel} {props.run.settings.aspectRatio} {props.run.settings.resolution}</small>
|
<div className="mediaTaskMetaLine">
|
||||||
</p>
|
<Sparkles size={16} />
|
||||||
<time dateTime={props.run.createdAt}>{formatRunTime(props.run.createdAt)}</time>
|
<time dateTime={props.run.createdAt}>{formatRunDateTime(props.run.createdAt)}</time>
|
||||||
|
<span aria-hidden="true">|</span>
|
||||||
|
<strong>{props.run.modelLabel}</strong>
|
||||||
|
<span>{props.run.mode === 'video' ? '视频生成' : references.length ? '图像编辑' : '图像生成'}</span>
|
||||||
|
<span aria-hidden="true">|</span>
|
||||||
|
<span>{taskMeta}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mediaTaskPromptLine">
|
||||||
|
<span className="mediaTaskPromptText">{promptParts}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="mediaTaskStatus" data-status={props.run.status}>{status}</span>
|
<span className="mediaTaskStatus" data-status={props.run.status}>{status}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mediaPreviewStage" data-count={expectedCount}>
|
{errorText && (
|
||||||
|
<div className="mediaTaskError">
|
||||||
|
<strong>错误详情</strong>
|
||||||
|
<span>{errorText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mediaPreviewStage" data-count={expectedCount} data-preview-state={previewState}>
|
||||||
{backdropItem && <img aria-hidden="true" className="mediaPreviewBackdrop" src={backdropItem.src} alt="" />}
|
{backdropItem && <img aria-hidden="true" className="mediaPreviewBackdrop" src={backdropItem.src} alt="" />}
|
||||||
<div className="mediaGrid" data-count={expectedCount} style={style}>
|
<div className="mediaGrid" data-count={expectedCount} data-preview-state={previewState} style={style}>
|
||||||
{Array.from({ length: expectedCount }).map((_, index) => (
|
{Array.from({ length: expectedCount }).map((_, index) => (
|
||||||
<MediaTile
|
<MediaTile
|
||||||
expectedCount={expectedCount}
|
expectedCount={expectedCount}
|
||||||
@ -545,12 +569,6 @@ function MediaTaskCard(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorText && (
|
|
||||||
<div className="mediaTaskError">
|
|
||||||
<strong>错误详情</strong>
|
|
||||||
<span>{errorText}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<footer className="mediaTaskActions">
|
<footer className="mediaTaskActions">
|
||||||
{items[0] ? (
|
{items[0] ? (
|
||||||
<Button asChild size="sm" variant="secondary">
|
<Button asChild size="sm" variant="secondary">
|
||||||
@ -582,6 +600,57 @@ function MediaTaskCard(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaTaskReferenceStack(props: { references: PlaygroundUpload[] }) {
|
||||||
|
const visibleReferences = props.references.slice(0, 8);
|
||||||
|
const overflowCount = props.references.length - visibleReferences.length;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="mediaTaskReferenceStack"
|
||||||
|
style={{ '--reference-count': Math.max(1, visibleReferences.length) } as CSSProperties}
|
||||||
|
title={props.references.map((item) => item.name).join('\n')}
|
||||||
|
>
|
||||||
|
{visibleReferences.map((item, index) => (
|
||||||
|
<span
|
||||||
|
className="mediaTaskReferenceCard"
|
||||||
|
data-kind={item.kind}
|
||||||
|
key={`${item.id}-${index}`}
|
||||||
|
style={taskReferenceCardStyle(index)}
|
||||||
|
>
|
||||||
|
<MediaTaskReferencePreview item={item} />
|
||||||
|
<small>{referenceKindLabel(item.kind)}</small>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{overflowCount > 0 && (
|
||||||
|
<span className="mediaTaskReferenceOverflow">+{overflowCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTaskReferencePreview(props: { item: PlaygroundUpload }) {
|
||||||
|
if (props.item.kind === 'image') {
|
||||||
|
return <img src={props.item.url} alt="" draggable={false} />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'video') {
|
||||||
|
return <video src={props.item.url} muted playsInline preload="metadata" />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'audio') {
|
||||||
|
return <Music2 size={16} />;
|
||||||
|
}
|
||||||
|
return <FileText size={16} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptResourceTag(props: { item: PlaygroundUpload; references: PlaygroundUpload[] }) {
|
||||||
|
return (
|
||||||
|
<span className="mediaPromptResourceTag" contentEditable={false}>
|
||||||
|
<span className="mediaPromptResourceThumb">
|
||||||
|
<MediaTaskReferencePreview item={props.item} />
|
||||||
|
</span>
|
||||||
|
<span>{mediaReferenceMentionLabel(props.item, props.references)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MediaTile(props: {
|
function MediaTile(props: {
|
||||||
expectedCount: number;
|
expectedCount: number;
|
||||||
index: number;
|
index: number;
|
||||||
@ -592,7 +661,7 @@ function MediaTile(props: {
|
|||||||
const isLoading = props.status === 'submitting' || props.status === 'queued' || props.status === 'running';
|
const isLoading = props.status === 'submitting' || props.status === 'queued' || props.status === 'running';
|
||||||
const isFailed = props.status === 'failed' || props.status === 'cancelled';
|
const isFailed = props.status === 'failed' || props.status === 'cancelled';
|
||||||
return (
|
return (
|
||||||
<div className="mediaTile" data-count={props.expectedCount} data-empty={!props.item && !isLoading} data-kind={props.mode}>
|
<div className="mediaTile" data-count={props.expectedCount} data-empty={!props.item && !isLoading} data-kind={props.mode} data-placeholder={!props.item}>
|
||||||
{props.item?.type === 'video' && (
|
{props.item?.type === 'video' && (
|
||||||
<video controls muted playsInline poster={props.item.poster}>
|
<video controls muted playsInline poster={props.item.poster}>
|
||||||
<source src={props.item.src} />
|
<source src={props.item.src} />
|
||||||
@ -630,6 +699,193 @@ function mediaRunErrorText(run: MediaGenerationRun) {
|
|||||||
return gatewayTaskErrorText(run.task, '') || run.error || '';
|
return gatewayTaskErrorText(run.task, '') || run.error || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaResourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
|
||||||
|
const taskReferenceTiltValues = [-10, 8, -5, 9, -7, 5, -8, 6];
|
||||||
|
const taskReferenceYValues = [0, 3, -1, 2, -2, 4, 1, -3];
|
||||||
|
|
||||||
|
function promptDisplayParts(raw: string, references: PlaygroundUpload[]): ReactNode[] {
|
||||||
|
if (!raw.includes('<<<playground-resource:')) return [raw];
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
const byId = new Map(references.map((item) => [item.id, item]));
|
||||||
|
const usedIds = new Set<string>();
|
||||||
|
let fallbackIndex = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const re = new RegExp(mediaResourceTokenPattern);
|
||||||
|
while ((match = re.exec(raw)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(raw.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
const id = match[1] ?? '';
|
||||||
|
let item = byId.get(id);
|
||||||
|
if (item) {
|
||||||
|
usedIds.add(item.id);
|
||||||
|
} else {
|
||||||
|
while (fallbackIndex < references.length && usedIds.has(references[fallbackIndex]!.id)) {
|
||||||
|
fallbackIndex += 1;
|
||||||
|
}
|
||||||
|
item = references[fallbackIndex];
|
||||||
|
fallbackIndex += 1;
|
||||||
|
if (item) usedIds.add(item.id);
|
||||||
|
}
|
||||||
|
if (item) {
|
||||||
|
parts.push(<PromptResourceTag item={item} references={references} key={`${match.index}-${item.id}`} />);
|
||||||
|
} else {
|
||||||
|
parts.push('@资产');
|
||||||
|
}
|
||||||
|
lastIndex = match.index + (match[0]?.length ?? 0);
|
||||||
|
}
|
||||||
|
if (lastIndex < raw.length) {
|
||||||
|
parts.push(raw.slice(lastIndex));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskReferenceCardStyle(index: number) {
|
||||||
|
const valueIndex = index % taskReferenceTiltValues.length;
|
||||||
|
return {
|
||||||
|
'--reference-index': index,
|
||||||
|
'--reference-tilt': `${taskReferenceTiltValues[valueIndex]}deg`,
|
||||||
|
'--reference-y': `${taskReferenceYValues[valueIndex]}px`,
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaReferenceMentionLabel(item: PlaygroundUpload, references: PlaygroundUpload[]) {
|
||||||
|
return `@${promptReferenceKindLabel(item.kind)}${referenceKindIndex(item, references)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceKindIndex(item: PlaygroundUpload, references: PlaygroundUpload[]) {
|
||||||
|
const sameKind = references.filter((reference) => reference.kind === item.kind);
|
||||||
|
return Math.max(1, sameKind.findIndex((reference) => reference.id === item.id) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaReferenceItems(run: MediaGenerationRun): PlaygroundUpload[] {
|
||||||
|
const uploads = normalizeReferenceUploads(run.uploads);
|
||||||
|
if (uploads.length) return uploads;
|
||||||
|
return referencesFromTaskRequest(run.task?.request, run.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReferenceUploads(value: unknown): PlaygroundUpload[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item, index) => normalizeReferenceUpload(item, index))
|
||||||
|
.filter((item): item is PlaygroundUpload => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReferenceUpload(value: unknown, index: number): PlaygroundUpload | undefined {
|
||||||
|
const record = recordFromUnknown(value);
|
||||||
|
if (!record) return undefined;
|
||||||
|
const url = stringFromUnknown(record.url);
|
||||||
|
const kind = referenceKindFromUnknown(record.kind, url);
|
||||||
|
if (!url || !kind) return undefined;
|
||||||
|
const size = numberFromUnknown(record.size);
|
||||||
|
return {
|
||||||
|
contentType: stringFromUnknown(record.contentType),
|
||||||
|
id: stringFromUnknown(record.id) || `${kind}-${index}-${url}`,
|
||||||
|
kind,
|
||||||
|
name: stringFromUnknown(record.name) || `${referenceKindLabel(kind)} ${index + 1}`,
|
||||||
|
raw: recordFromUnknown(record.raw) ?? {},
|
||||||
|
role: record.role === 'first_frame' || record.role === 'last_frame' ? record.role : undefined,
|
||||||
|
size: size && size > 0 ? Math.round(size) : 0,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencesFromTaskRequest(request: unknown, mode: Exclude<PlaygroundMode, 'chat'>): PlaygroundUpload[] {
|
||||||
|
const record = recordFromUnknown(request);
|
||||||
|
if (!record) return [];
|
||||||
|
const references: PlaygroundUpload[] = [];
|
||||||
|
if (Array.isArray(record.content)) {
|
||||||
|
record.content.forEach((item) => {
|
||||||
|
const content = recordFromUnknown(item);
|
||||||
|
if (!content) return;
|
||||||
|
appendReferenceFromContentPart(references, content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (mode === 'image') {
|
||||||
|
appendImageReferencesFromValue(references, record.image);
|
||||||
|
appendImageReferencesFromValue(references, record.images);
|
||||||
|
appendImageReferencesFromValue(references, record.input_image);
|
||||||
|
appendImageReferencesFromValue(references, record.input_images);
|
||||||
|
}
|
||||||
|
return dedupeReferenceUploads(references);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendReferenceFromContentPart(references: PlaygroundUpload[], part: Record<string, unknown>) {
|
||||||
|
const type = stringFromUnknown(part.type);
|
||||||
|
if (type === 'image_url') {
|
||||||
|
appendReferenceUrl(references, 'image', firstString(nestedString(part.image_url, 'url'), part.url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'video_url') {
|
||||||
|
appendReferenceUrl(references, 'video', firstString(nestedString(part.video_url, 'url'), part.url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'audio_url') {
|
||||||
|
appendReferenceUrl(references, 'audio', firstString(nestedString(part.audio_url, 'url'), part.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendImageReferencesFromValue(references: PlaygroundUpload[], value: unknown) {
|
||||||
|
if (!value) return;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
appendReferenceUrl(references, 'image', value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => appendImageReferencesFromValue(references, item));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = recordFromUnknown(value);
|
||||||
|
if (!record) return;
|
||||||
|
appendReferenceUrl(references, 'image', firstString(record.url, nestedString(record.image_url, 'url'), record.image_url, record.imageUrl, record.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendReferenceUrl(references: PlaygroundUpload[], kind: PlaygroundUploadKind, rawUrl: unknown) {
|
||||||
|
const url = stringFromUnknown(rawUrl);
|
||||||
|
if (!url) return;
|
||||||
|
const resolvedUrl = resolveApiAssetUrl(url);
|
||||||
|
references.push({
|
||||||
|
contentType: '',
|
||||||
|
id: `${kind}-${references.length}-${resolvedUrl}`,
|
||||||
|
kind,
|
||||||
|
name: `${referenceKindLabel(kind)} ${references.filter((item) => item.kind === kind).length + 1}`,
|
||||||
|
raw: {},
|
||||||
|
size: 0,
|
||||||
|
url: resolvedUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeReferenceUploads(references: PlaygroundUpload[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return references.filter((item) => {
|
||||||
|
const key = `${item.kind}:${item.url}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceKindFromUnknown(value: unknown, url: string): PlaygroundUploadKind | undefined {
|
||||||
|
if (value === 'image' || value === 'video' || value === 'audio' || value === 'file') return value;
|
||||||
|
if (/\.(png|jpe?g|webp|gif|bmp|avif|svg)(\?|#|$)/i.test(url)) return 'image';
|
||||||
|
if (/\.(mp4|mov|webm|m4v|avi|mkv)(\?|#|$)/i.test(url)) return 'video';
|
||||||
|
if (/\.(mp3|m4a|wav|aac|flac|ogg|opus)(\?|#|$)/i.test(url)) return 'audio';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceKindLabel(kind: PlaygroundUploadKind) {
|
||||||
|
if (kind === 'image') return '图像';
|
||||||
|
if (kind === 'video') return '视频';
|
||||||
|
if (kind === 'audio') return '音频';
|
||||||
|
return '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptReferenceKindLabel(kind: PlaygroundUploadKind) {
|
||||||
|
if (kind === 'image') return '图片';
|
||||||
|
return referenceKindLabel(kind);
|
||||||
|
}
|
||||||
|
|
||||||
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
|
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
|
||||||
const record = recordFromUnknown(entry);
|
const record = recordFromUnknown(entry);
|
||||||
if (!record) return undefined;
|
if (!record) return undefined;
|
||||||
@ -662,8 +918,23 @@ function mediaStatusText(run: MediaGenerationRun) {
|
|||||||
return run.status;
|
return run.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRunTime(value: string) {
|
function formatRunDateTime(value: string) {
|
||||||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
const pad = (item: number) => String(item).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaTaskMetaText(run: MediaGenerationRun) {
|
||||||
|
const items = [run.settings.aspectRatio, run.settings.resolution];
|
||||||
|
if (run.mode === 'video') {
|
||||||
|
items.push(`${run.settings.durationSeconds}s`);
|
||||||
|
if (run.settings.outputAudio) items.push('有声音');
|
||||||
|
} else {
|
||||||
|
const count = mediaOutputCount(run.settings);
|
||||||
|
if (count > 1) items.push(`${count}张`);
|
||||||
|
}
|
||||||
|
return items.filter(Boolean).join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cssAspectRatio(settings: MediaGenerationSettings) {
|
function cssAspectRatio(settings: MediaGenerationSettings) {
|
||||||
|
|||||||
601
apps/web/src/pages/playground-prompt-mention.tsx
Normal file
601
apps/web/src/pages/playground-prompt-mention.tsx
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
|
import { FileText, Music2, Video } from 'lucide-react';
|
||||||
|
|
||||||
|
export type PlaygroundMentionUploadKind = 'audio' | 'file' | 'image' | 'video';
|
||||||
|
|
||||||
|
export interface PlaygroundMentionUpload {
|
||||||
|
id: string;
|
||||||
|
kind: PlaygroundMentionUploadKind;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceTokenPrefix = '<<<playground-resource:';
|
||||||
|
const resourceTokenSuffix = '>>>';
|
||||||
|
const resourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
|
||||||
|
const resourceTokenWithSpacePattern = /<<<playground-resource:([^>]+)>>>\s?/g;
|
||||||
|
|
||||||
|
export function buildPlaygroundResourceToken(id: string) {
|
||||||
|
return `${resourceTokenPrefix}${id}${resourceTokenSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeInvalidPlaygroundResourceTokens(raw: string, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
if (!raw.includes(resourceTokenPrefix)) return raw;
|
||||||
|
const validIds = new Set(uploads.map((item) => item.id));
|
||||||
|
return raw.replace(resourceTokenWithSpacePattern, (full, id: string) => validIds.has(id) ? full : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replacePlaygroundResourceTokens(
|
||||||
|
raw: string,
|
||||||
|
uploads: PlaygroundMentionUpload[],
|
||||||
|
mode: 'image' | 'video',
|
||||||
|
) {
|
||||||
|
if (!raw.includes(resourceTokenPrefix)) return raw;
|
||||||
|
const byId = new Map(uploads.map((item) => [item.id, item]));
|
||||||
|
return raw.replace(resourceTokenPattern, (full, id: string) => {
|
||||||
|
const item = byId.get(id);
|
||||||
|
if (!item) return '';
|
||||||
|
return resourcePromptLabel(item, uploads, mode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replacePlaygroundResourceTokensForDisplay(raw: string, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
if (!raw.includes(resourceTokenPrefix)) return raw;
|
||||||
|
const byId = new Map(uploads.map((item) => [item.id, item]));
|
||||||
|
const usedIds = new Set<string>();
|
||||||
|
let fallbackIndex = 0;
|
||||||
|
return raw.replace(resourceTokenPattern, (full, id: string) => {
|
||||||
|
let item = byId.get(id);
|
||||||
|
if (item) {
|
||||||
|
usedIds.add(item.id);
|
||||||
|
} else {
|
||||||
|
while (fallbackIndex < uploads.length && usedIds.has(uploads[fallbackIndex]!.id)) {
|
||||||
|
fallbackIndex += 1;
|
||||||
|
}
|
||||||
|
item = uploads[fallbackIndex];
|
||||||
|
fallbackIndex += 1;
|
||||||
|
if (item) usedIds.add(item.id);
|
||||||
|
}
|
||||||
|
if (!item) return '@资产';
|
||||||
|
return mentionDisplayLabel(item, uploads);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaygroundPromptMentionInput(props: {
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
uploads: PlaygroundMentionUpload[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const editableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const blurTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
const [text, setText] = useState(props.value);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [hasEditableContent, setHasEditableContent] = useState(() => promptTextHasContent(props.value));
|
||||||
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
|
const [mentionAtIndex, setMentionAtIndex] = useState(-1);
|
||||||
|
const [mentionSearch, setMentionSearch] = useState('');
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, placement: 'bottom' as 'bottom' | 'top' });
|
||||||
|
const showPlaceholder = !hasEditableContent;
|
||||||
|
|
||||||
|
const uploadSignature = useMemo(
|
||||||
|
() => props.uploads.map((item) => `${item.id}:${item.kind}:${item.name}:${item.url}`).join('|'),
|
||||||
|
[props.uploads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mentionItems = useMemo(() => props.uploads.map((item, index) => ({
|
||||||
|
item,
|
||||||
|
label: mentionDisplayLabel(item, props.uploads),
|
||||||
|
searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} 资产 素材 resource asset ${index + 1}`.toLowerCase(),
|
||||||
|
token: buildPlaygroundResourceToken(item.id),
|
||||||
|
})), [props.uploads]);
|
||||||
|
|
||||||
|
const filteredMentionItems = useMemo(() => {
|
||||||
|
const keyword = mentionSearch.trim().toLowerCase();
|
||||||
|
if (!keyword) return mentionItems;
|
||||||
|
return mentionItems.filter((item) => item.searchText.includes(keyword));
|
||||||
|
}, [mentionItems, mentionSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.value === text) {
|
||||||
|
setHasEditableContent(promptTextHasContent(props.value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setText(props.value);
|
||||||
|
setHasEditableContent(promptTextHasContent(props.value));
|
||||||
|
if (!isComposingRef.current) {
|
||||||
|
requestAnimationFrame(() => renderToEditable(props.value));
|
||||||
|
}
|
||||||
|
}, [focused, props.value, text]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleaned = removeInvalidPlaygroundResourceTokens(props.value, props.uploads);
|
||||||
|
if (cleaned !== props.value) {
|
||||||
|
setText(cleaned);
|
||||||
|
setHasEditableContent(promptTextHasContent(cleaned));
|
||||||
|
props.onChange(cleaned);
|
||||||
|
requestAnimationFrame(() => renderToEditable(cleaned));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.value !== text) {
|
||||||
|
setText(props.value);
|
||||||
|
setHasEditableContent(promptTextHasContent(props.value));
|
||||||
|
}
|
||||||
|
if (!focused) {
|
||||||
|
requestAnimationFrame(() => renderToEditable(props.value));
|
||||||
|
}
|
||||||
|
}, [uploadSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => renderToEditable(text));
|
||||||
|
return () => {
|
||||||
|
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateMentionDropdownPosition = useCallback(() => {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
const root = editable?.parentElement;
|
||||||
|
if (!editable || !root) return;
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const caretRect = getCaretClientRect(editable) ?? editable.getBoundingClientRect();
|
||||||
|
const dropdown = dropdownRef.current;
|
||||||
|
const viewportPadding = 8;
|
||||||
|
const gap = 6;
|
||||||
|
const dropdownWidth = Math.min(dropdown?.offsetWidth ?? 320, Math.max(180, window.innerWidth - viewportPadding * 2));
|
||||||
|
const dropdownHeight = Math.min(dropdown?.offsetHeight ?? 220, Math.max(120, window.innerHeight - viewportPadding * 2));
|
||||||
|
const minLeft = viewportPadding - rootRect.left;
|
||||||
|
const maxLeft = window.innerWidth - viewportPadding - dropdownWidth - rootRect.left;
|
||||||
|
const left = clamp(caretRect.left - rootRect.left, minLeft, Math.max(minLeft, maxLeft));
|
||||||
|
const belowTop = caretRect.bottom + gap;
|
||||||
|
const aboveTop = caretRect.top - dropdownHeight - gap;
|
||||||
|
const shouldOpenAbove = belowTop + dropdownHeight > window.innerHeight - viewportPadding && aboveTop >= viewportPadding;
|
||||||
|
const viewportTop = shouldOpenAbove
|
||||||
|
? Math.max(viewportPadding, aboveTop)
|
||||||
|
: Math.min(belowTop, window.innerHeight - viewportPadding - dropdownHeight);
|
||||||
|
setDropdownPosition({
|
||||||
|
top: viewportTop - rootRect.top,
|
||||||
|
left,
|
||||||
|
placement: shouldOpenAbove ? 'top' : 'bottom',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mentionOpen) return;
|
||||||
|
const frame = window.requestAnimationFrame(updateMentionDropdownPosition);
|
||||||
|
const handleViewportChange = () => updateMentionDropdownPosition();
|
||||||
|
window.addEventListener('resize', handleViewportChange);
|
||||||
|
window.addEventListener('scroll', handleViewportChange, true);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener('resize', handleViewportChange);
|
||||||
|
window.removeEventListener('scroll', handleViewportChange, true);
|
||||||
|
};
|
||||||
|
}, [filteredMentionItems.length, mentionOpen, mentionSearch, updateMentionDropdownPosition]);
|
||||||
|
|
||||||
|
function renderToEditable(nextText = text, caret?: number) {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
if (!editable) return;
|
||||||
|
editable.innerHTML = textToHtml(nextText, props.uploads);
|
||||||
|
setHasEditableContent(promptTextHasContent(nextText));
|
||||||
|
if (typeof caret === 'number') {
|
||||||
|
editable.focus();
|
||||||
|
setCaretOffset(editable, caret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitFromEditable(shouldInspectMention: boolean, event?: InputEvent) {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
if (!editable || props.disabled) return;
|
||||||
|
const nextText = serializeEditableToPlainText(editable);
|
||||||
|
setHasEditableContent(promptTextHasContent(nextText));
|
||||||
|
if (isComposingRef.current || event?.isComposing) return;
|
||||||
|
setText(nextText);
|
||||||
|
props.onChange(nextText);
|
||||||
|
if (!shouldInspectMention) return;
|
||||||
|
inspectMentionTrigger(nextText, getCaretOffset(editable), event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectMentionTrigger(nextText: string, caret: number, event?: InputEvent) {
|
||||||
|
const beforeCaret = nextText.slice(0, caret);
|
||||||
|
const atIndex = Math.max(beforeCaret.lastIndexOf('@'), beforeCaret.lastIndexOf('@'));
|
||||||
|
if (atIndex < 0) {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const search = beforeCaret.slice(atIndex + 1);
|
||||||
|
if (/\s/.test(search)) {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inputData = event?.data ?? '';
|
||||||
|
if (!mentionOpen && search.length > 0 && inputData !== '@' && inputData !== '@') {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMentionAtIndex(atIndex);
|
||||||
|
setMentionSearch(search);
|
||||||
|
setMentionOpen(true);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
requestAnimationFrame(updateMentionDropdownPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMention() {
|
||||||
|
setMentionOpen(false);
|
||||||
|
setMentionSearch('');
|
||||||
|
setMentionAtIndex(-1);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMention(token: string) {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
if (!editable || mentionAtIndex < 0) return;
|
||||||
|
const currentText = serializeEditableToPlainText(editable);
|
||||||
|
const replaceEnd = Math.max(mentionAtIndex + 1, mentionAtIndex + 1 + mentionSearch.length);
|
||||||
|
const before = currentText.slice(0, mentionAtIndex);
|
||||||
|
const after = currentText.slice(replaceEnd);
|
||||||
|
const nextText = `${before}${token} ${after}`;
|
||||||
|
const nextCaret = before.length + token.length + 1;
|
||||||
|
setText(nextText);
|
||||||
|
setHasEditableContent(promptTextHasContent(nextText));
|
||||||
|
props.onChange(nextText);
|
||||||
|
closeMention();
|
||||||
|
requestAnimationFrame(() => renderToEditable(nextText, nextCaret));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (props.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mentionOpen || !filteredMentionItems.length) return;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightIndex((current) => (current + 1) % filteredMentionItems.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightIndex((current) => (current - 1 + filteredMentionItems.length) % filteredMentionItems.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = filteredMentionItems[Math.min(highlightIndex, filteredMentionItems.length - 1)];
|
||||||
|
if (item) applyMention(item.token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMention();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="promptMentionInput">
|
||||||
|
<div
|
||||||
|
ref={editableRef}
|
||||||
|
className="promptMentionEditable"
|
||||||
|
contentEditable={!props.disabled}
|
||||||
|
role="textbox"
|
||||||
|
aria-label={props.placeholder}
|
||||||
|
aria-multiline="true"
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onBlur={() => {
|
||||||
|
blurTimerRef.current = window.setTimeout(() => {
|
||||||
|
setFocused(false);
|
||||||
|
closeMention();
|
||||||
|
commitFromEditable(false);
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
setHasEditableContent(editablePromptTextHasContent(editableRef.current));
|
||||||
|
requestAnimationFrame(() => commitFromEditable(true));
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
|
||||||
|
}}
|
||||||
|
onInput={(event) => commitFromEditable(true, event.nativeEvent instanceof InputEvent ? event.nativeEvent : undefined)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={(event) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const plainText = event.clipboardData.getData('text/plain');
|
||||||
|
document.execCommand('insertText', false, plainText);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showPlaceholder && <div className="promptMentionPlaceholder" aria-hidden="true">{props.placeholder}</div>}
|
||||||
|
{mentionOpen && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="promptMentionDropdown"
|
||||||
|
data-placement={dropdownPosition.placement}
|
||||||
|
style={{ left: dropdownPosition.left, top: dropdownPosition.top }}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{filteredMentionItems.length ? (
|
||||||
|
filteredMentionItems.map((candidate, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="promptMentionItem"
|
||||||
|
data-active={index === highlightIndex}
|
||||||
|
key={candidate.item.id}
|
||||||
|
onMouseEnter={() => setHighlightIndex(index)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
applyMention(candidate.token);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MentionThumb item={candidate.item} />
|
||||||
|
<span>
|
||||||
|
<strong>{candidate.label}</strong>
|
||||||
|
<small>{candidate.item.name}</small>
|
||||||
|
</span>
|
||||||
|
<em>{uploadKindChinese(candidate.item.kind)}</em>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="promptMentionEmpty">暂无可引用资源</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptTextHasContent(raw: string) {
|
||||||
|
return raw.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editablePromptTextHasContent(editable: HTMLElement | null) {
|
||||||
|
return editable ? promptTextHasContent(serializeEditableToPlainText(editable)) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentionThumb(props: { item: PlaygroundMentionUpload }) {
|
||||||
|
if (props.item.kind === 'image') {
|
||||||
|
return <img className="promptMentionThumb" src={props.item.url} alt="" draggable={false} />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'video') {
|
||||||
|
return <video className="promptMentionThumb" src={props.item.url} muted playsInline preload="metadata" />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'audio') {
|
||||||
|
return <Music2 className="promptMentionThumbIcon" size={16} />;
|
||||||
|
}
|
||||||
|
return <FileText className="promptMentionThumbIcon" size={16} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToHtml(raw: string, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
const uploadById = new Map(uploads.map((item) => [item.id, item]));
|
||||||
|
const parts: string[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const re = new RegExp(resourceTokenPattern);
|
||||||
|
while ((match = re.exec(raw)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(escapeHtml(raw.slice(lastIndex, match.index)));
|
||||||
|
}
|
||||||
|
const token = match[0] ?? '';
|
||||||
|
const id = match[1] ?? '';
|
||||||
|
const item = uploadById.get(id);
|
||||||
|
parts.push(item ? mentionChipHtml(token, item, uploads) : '');
|
||||||
|
lastIndex = match.index + token.length;
|
||||||
|
}
|
||||||
|
if (lastIndex < raw.length) {
|
||||||
|
parts.push(escapeHtml(raw.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
return parts.join('').replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionChipHtml(token: string, item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
const label = mentionDisplayLabel(item, uploads);
|
||||||
|
const thumb = item.kind === 'image'
|
||||||
|
? `<img class="promptMentionChipThumb" src="${escapeAttr(item.url)}" alt="" draggable="false">`
|
||||||
|
: item.kind === 'video'
|
||||||
|
? `<video class="promptMentionChipThumb" src="${escapeAttr(item.url)}" muted preload="metadata" playsinline></video>`
|
||||||
|
: `<span class="promptMentionChipThumb promptMentionChipThumbPlaceholder">${escapeHtml(uploadKindShort(item.kind))}</span>`;
|
||||||
|
return `<span contenteditable="false" class="promptMentionChip" data-token="${escapeAttr(token)}">${thumb}<span>${escapeHtml(label)}</span></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaretClientRect(editable: HTMLElement) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return null;
|
||||||
|
const originalRange = selection.getRangeAt(0);
|
||||||
|
if (!editable.contains(originalRange.startContainer)) return null;
|
||||||
|
const collapsedRange = originalRange.cloneRange();
|
||||||
|
collapsedRange.collapse(true);
|
||||||
|
let rect = collapsedRange.getBoundingClientRect();
|
||||||
|
if (rect.width || rect.height) return rect;
|
||||||
|
const marker = document.createElement('span');
|
||||||
|
marker.textContent = '\u200b';
|
||||||
|
const restoreRange = originalRange.cloneRange();
|
||||||
|
collapsedRange.insertNode(marker);
|
||||||
|
rect = marker.getBoundingClientRect();
|
||||||
|
marker.remove();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(restoreRange);
|
||||||
|
return rect.width || rect.height ? rect : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEditableToPlainText(editable: HTMLElement) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const walk = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
parts.push(node.textContent ?? '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const token = element.getAttribute('data-token');
|
||||||
|
if (token) {
|
||||||
|
parts.push(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'BR') {
|
||||||
|
parts.push('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'DIV' && parts.length && !parts[parts.length - 1]?.endsWith('\n')) {
|
||||||
|
parts.push('\n');
|
||||||
|
}
|
||||||
|
element.childNodes.forEach(walk);
|
||||||
|
};
|
||||||
|
editable.childNodes.forEach(walk);
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaretOffset(editable: HTMLElement) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return 0;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
let offset = 0;
|
||||||
|
const walk = (node: Node): boolean => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const textLength = (node.textContent ?? '').length;
|
||||||
|
if (node === range.startContainer) {
|
||||||
|
offset += range.startOffset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += textLength;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const token = element.getAttribute('data-token');
|
||||||
|
if (token) {
|
||||||
|
if (node === range.startContainer || element.contains(range.startContainer)) {
|
||||||
|
offset += range.startOffset > 0 ? token.length : 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += token.length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'BR') {
|
||||||
|
offset += 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
if (walk(child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const child of Array.from(editable.childNodes)) {
|
||||||
|
if (walk(child)) break;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaretOffset(editable: HTMLElement, targetOffset: number) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
let offset = 0;
|
||||||
|
let targetNode: Node | null = null;
|
||||||
|
let targetNodeOffset = 0;
|
||||||
|
const walk = (node: Node): boolean => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const textLength = (node.textContent ?? '').length;
|
||||||
|
if (offset + textLength >= targetOffset) {
|
||||||
|
targetNode = node;
|
||||||
|
targetNodeOffset = Math.max(0, targetOffset - offset);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += textLength;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const token = element.getAttribute('data-token');
|
||||||
|
if (token) {
|
||||||
|
if (offset + token.length >= targetOffset) {
|
||||||
|
targetNode = node;
|
||||||
|
targetNodeOffset = targetOffset <= offset ? 0 : 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += token.length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'BR') {
|
||||||
|
if (offset + 1 >= targetOffset) {
|
||||||
|
targetNode = node;
|
||||||
|
targetNodeOffset = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
if (walk(child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const child of Array.from(editable.childNodes)) {
|
||||||
|
if (walk(child)) break;
|
||||||
|
}
|
||||||
|
if (!targetNode) {
|
||||||
|
targetNode = editable;
|
||||||
|
targetNodeOffset = editable.childNodes.length;
|
||||||
|
}
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(targetNode, targetNode.nodeType === Node.TEXT_NODE ? targetNodeOffset : Math.min(targetNodeOffset, targetNode.childNodes.length));
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionDisplayLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
return `@${uploadKindChinese(item.kind)} ${uploadKindIndex(item, uploads)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourcePromptLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[], mode: 'image' | 'video') {
|
||||||
|
const kind = mode === 'image' ? 'image' : uploadKindEnglish(item.kind);
|
||||||
|
return `${kind} ${uploadKindIndex(item, uploads)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindIndex(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
const sameKind = uploads.filter((upload) => upload.kind === item.kind);
|
||||||
|
return Math.max(1, sameKind.findIndex((upload) => upload.id === item.id) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindChinese(kind: PlaygroundMentionUploadKind) {
|
||||||
|
if (kind === 'image') return '图像';
|
||||||
|
if (kind === 'video') return '视频';
|
||||||
|
if (kind === 'audio') return '音频';
|
||||||
|
return '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindEnglish(kind: PlaygroundMentionUploadKind) {
|
||||||
|
if (kind === 'image') return 'image';
|
||||||
|
if (kind === 'video') return 'video';
|
||||||
|
if (kind === 'audio') return 'audio';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindShort(kind: PlaygroundMentionUploadKind) {
|
||||||
|
if (kind === 'image') return '图';
|
||||||
|
if (kind === 'video') return '视';
|
||||||
|
if (kind === 'audio') return '音';
|
||||||
|
return '文';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value: string) {
|
||||||
|
return escapeHtml(value).replace(/"/g, '"');
|
||||||
|
}
|
||||||
120
apps/web/src/pages/playground-shared.tsx
Normal file
120
apps/web/src/pages/playground-shared.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { GatewayApiKey, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { Bot, Image as ImageIcon, Video } from 'lucide-react';
|
||||||
|
import { Select } from '../components/ui';
|
||||||
|
import type { PlaygroundMode } from '../types';
|
||||||
|
import type { PlaygroundVideoCreateMode } from './playground-upload';
|
||||||
|
|
||||||
|
export type VideoCreateMode = PlaygroundVideoCreateMode;
|
||||||
|
|
||||||
|
export interface ModelOption {
|
||||||
|
count: number;
|
||||||
|
label: string;
|
||||||
|
models: PlatformModel[];
|
||||||
|
provider: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modeOptions: Array<{ description: string; icon: ReactNode; label: string; value: PlaygroundMode }> = [
|
||||||
|
{ value: 'chat', label: '大模型对话', description: '对话、推理、结构化输出', icon: <Bot size={16} /> },
|
||||||
|
{ value: 'image', label: '图像生成', description: '文生图、图像编辑参数预览', icon: <ImageIcon size={16} /> },
|
||||||
|
{ value: 'video', label: '视频生成', description: '图生视频、文生视频任务测试', icon: <Video size={16} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const videoModeOptions: Array<{ label: string; value: VideoCreateMode }> = [
|
||||||
|
{ value: 'text_to_video', label: '文生视频' },
|
||||||
|
{ value: 'first_last_frame', label: '首尾帧' },
|
||||||
|
{ value: 'omni_reference', label: '全能参考' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const placeholderByMode: Record<PlaygroundMode, string> = {
|
||||||
|
chat: '输入问题、角色设定或测试提示词,支持 OpenAI 兼容格式验证...',
|
||||||
|
image: '描述你想生成的画面,例如:未来城市中的玻璃温室,晨光,电影级构图...',
|
||||||
|
video: '描述视频镜头、主体运动和风格,例如:低角度跟拍,一辆复古跑车穿过雨夜街道...',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quickPrompts: Record<PlaygroundMode, string[]> = {
|
||||||
|
chat: ['写一个产品发布摘要', '生成接口调用示例', '分析失败重试策略'],
|
||||||
|
image: ['产品海报', '角色设定图', '电商主图'],
|
||||||
|
video: ['5 秒运镜', '首帧转视频', '宣传短片'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveSelectedApiKeyId(apiKeys: GatewayApiKey[], secretsById: Record<string, string>, selectedApiKeyId: string) {
|
||||||
|
if (selectedApiKeyId && secretsById[selectedApiKeyId]) return selectedApiKeyId;
|
||||||
|
const firstUsable = apiKeys.find((item) => Boolean(secretsById[item.id]));
|
||||||
|
return firstUsable?.id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiKeyNoticeText(apiKeys: GatewayApiKey[], secretsById: Record<string, string>) {
|
||||||
|
if (!apiKeys.length) return '当前账号还没有可用 API Key,请先创建一个 Key。';
|
||||||
|
if (!apiKeys.some((item) => Boolean(secretsById[item.id]))) {
|
||||||
|
return '当前没有可用于在线测试的完整 API Key,请重新加载或创建一个 Key。';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelOptionLabel(option: ModelOption) {
|
||||||
|
const count = option.count > 1 ? ` · ${option.count} 个客户端` : '';
|
||||||
|
const provider = option.provider ? ` · ${option.provider}` : '';
|
||||||
|
return `${option.label}${provider}${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeySelect(props: {
|
||||||
|
apiKeySecretsById: Record<string, string>;
|
||||||
|
apiKeys: GatewayApiKey[];
|
||||||
|
selectedApiKeyId: string;
|
||||||
|
onApiKeyChange: (apiKeyId: string) => void;
|
||||||
|
}) {
|
||||||
|
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
className="playgroundApiKeySelect"
|
||||||
|
value={activeApiKeyId}
|
||||||
|
disabled={!props.apiKeys.length}
|
||||||
|
onChange={(event) => props.onApiKeyChange(event.target.value)}
|
||||||
|
>
|
||||||
|
{!activeApiKeyId && <option value="">{props.apiKeys.length ? '选择 API Key' : '暂无 API Key'}</option>}
|
||||||
|
{props.apiKeys.map((item) => {
|
||||||
|
const usable = Boolean(props.apiKeySecretsById[item.id]);
|
||||||
|
return (
|
||||||
|
<option value={item.id} key={item.id} disabled={!usable}>
|
||||||
|
{item.name} · {item.keyPrefix}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModeSwitch(props: {
|
||||||
|
activeMode: PlaygroundMode;
|
||||||
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="playgroundModeSwitch">
|
||||||
|
{modeOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.value}
|
||||||
|
data-active={props.activeMode === item.value}
|
||||||
|
onClick={() => props.onModeChange(item.value)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaygroundGreeting(props: {
|
||||||
|
activeMode: { description: string; label: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="playgroundGreeting">
|
||||||
|
<span>你好,想测试什么?</span>
|
||||||
|
<strong>{props.activeMode.label}</strong>
|
||||||
|
<small>{props.activeMode.description}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
742
apps/web/src/pages/playground-upload.tsx
Normal file
742
apps/web/src/pages/playground-upload.tsx
Normal file
@ -0,0 +1,742 @@
|
|||||||
|
import { useRef, useState, type CSSProperties } from 'react';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
LoaderCircle,
|
||||||
|
Music2,
|
||||||
|
Paperclip,
|
||||||
|
Plus,
|
||||||
|
Repeat2,
|
||||||
|
Video,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { uploadFileToStorage } from '../api';
|
||||||
|
import type { VideoGenerationContent } from '../api';
|
||||||
|
import type { PlaygroundMode } from '../types';
|
||||||
|
|
||||||
|
export type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
|
||||||
|
export type PlaygroundUploadRole = 'first_frame' | 'last_frame';
|
||||||
|
export type PlaygroundVideoCreateMode = 'text_to_video' | 'first_last_frame' | 'omni_reference';
|
||||||
|
|
||||||
|
export interface PlaygroundUpload {
|
||||||
|
contentType: string;
|
||||||
|
id: string;
|
||||||
|
kind: PlaygroundUploadKind;
|
||||||
|
name: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
role?: PlaygroundUploadRole;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenAIChatContentPart =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'image_url'; image_url: { url: string } }
|
||||||
|
| { type: 'video_url'; video_url: { url: string } }
|
||||||
|
| { type: 'audio_url'; audio_url: { url: string } }
|
||||||
|
| { type: 'file_url'; file_url: { filename: string; url: string } };
|
||||||
|
|
||||||
|
export const mediaUploadAccept = 'image/*,video/*,audio/*';
|
||||||
|
export const imageOnlyUploadAccept = 'image/*';
|
||||||
|
export const chatUploadAccept = [
|
||||||
|
mediaUploadAccept,
|
||||||
|
'.csv',
|
||||||
|
'.doc',
|
||||||
|
'.docx',
|
||||||
|
'.json',
|
||||||
|
'.jsonl',
|
||||||
|
'.md',
|
||||||
|
'.markdown',
|
||||||
|
'.pdf',
|
||||||
|
'.ppt',
|
||||||
|
'.pptx',
|
||||||
|
'.txt',
|
||||||
|
'.xls',
|
||||||
|
'.xlsx',
|
||||||
|
'.yaml',
|
||||||
|
'.yml',
|
||||||
|
'application/json',
|
||||||
|
'application/msword',
|
||||||
|
'application/pdf',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'text/*',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
export function ComposerUploadButton(props: {
|
||||||
|
accept: string;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
uploading?: boolean;
|
||||||
|
onFiles?: (files: File[]) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const disabled = props.disabled || props.uploading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="composerUpload"
|
||||||
|
aria-label="上传附件"
|
||||||
|
data-active={props.active === true}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Paperclip size={18} />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.currentTarget.files ?? []);
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
props.onFiles?.(files);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaygroundReferencePicker(props: {
|
||||||
|
accept: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
mode: PlaygroundMode;
|
||||||
|
uploadLabel?: string;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
|
videoMode?: PlaygroundVideoCreateMode;
|
||||||
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
onSwapFrames?: () => void;
|
||||||
|
}) {
|
||||||
|
if (props.mode === 'video' && props.videoMode === 'first_last_frame') {
|
||||||
|
return (
|
||||||
|
<FirstLastFramePicker
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={props.disabled}
|
||||||
|
uploads={props.uploads}
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
onSwapFrames={props.onSwapFrames}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StackedReferencePicker
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={props.disabled}
|
||||||
|
uploadLabel={props.uploadLabel ?? (props.mode === 'chat' ? '上传附件' : '参考内容')}
|
||||||
|
uploads={props.uploads}
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackedReferencePicker(props: {
|
||||||
|
accept: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
uploadLabel: string;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
|
onFiles?: (files: File[]) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [hoveredId, setHoveredId] = useState('');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hoveredUpload = props.uploads.find((item) => item.id === hoveredId);
|
||||||
|
const disabled = props.disabled || props.uploading || !props.onFiles;
|
||||||
|
const uploadCardIndex = props.uploads.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mediaReferenceStack"
|
||||||
|
data-expanded={expanded}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setExpanded(false);
|
||||||
|
setHoveredId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hoveredUpload && <div className="mediaReferenceTooltip">{hoveredUpload.name}</div>}
|
||||||
|
<div
|
||||||
|
className="mediaReferenceStackCards"
|
||||||
|
data-empty={!props.uploads.length}
|
||||||
|
style={{ '--reference-count': Math.max(1, props.uploads.length + 1) } as CSSProperties}
|
||||||
|
>
|
||||||
|
{props.uploads.map((item, index) => (
|
||||||
|
<div
|
||||||
|
className="mediaReferenceCard"
|
||||||
|
data-hovered={hoveredId === item.id}
|
||||||
|
data-kind={item.kind}
|
||||||
|
key={item.id}
|
||||||
|
style={referenceCardStyle(index)}
|
||||||
|
title={item.name}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setExpanded(true);
|
||||||
|
setHoveredId(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReferencePreview item={item} />
|
||||||
|
{item.kind !== 'image' && <span className="mediaReferenceDuration">{uploadKindLabel(item.kind)}</span>}
|
||||||
|
{props.onRemove && hoveredId === item.id && (
|
||||||
|
<button type="button" className="mediaReferenceRemove" aria-label={`删除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mediaReferenceCard mediaReferenceUploadCard"
|
||||||
|
aria-label={props.uploadLabel}
|
||||||
|
data-has-uploads={props.uploads.length > 0}
|
||||||
|
data-uploading={Boolean(props.uploading)}
|
||||||
|
disabled={disabled}
|
||||||
|
style={referenceCardStyle(uploadCardIndex)}
|
||||||
|
title={props.uploadLabel}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onMouseEnter={() => setHoveredId('')}
|
||||||
|
>
|
||||||
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Plus size={20} />}
|
||||||
|
<span>{props.uploadLabel}</span>
|
||||||
|
</button>
|
||||||
|
{props.uploads.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mediaReferenceAdd"
|
||||||
|
aria-label={props.uploadLabel}
|
||||||
|
data-uploading={Boolean(props.uploading)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={props.uploadLabel}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setExpanded(false);
|
||||||
|
setHoveredId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={15} /> : <Plus size={17} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.currentTarget.files ?? []);
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
props.onFiles?.(files);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceTiltValues = [-7, 6, -3, 8, -5, 4, -8, 5];
|
||||||
|
const referenceXValues = [0, -5, 6, -3, 4, -6, 3, -4];
|
||||||
|
const referenceYValues = [0, 3, -1, 4, 1, 5, 2, -2];
|
||||||
|
|
||||||
|
function referenceCardStyle(index: number) {
|
||||||
|
const valueIndex = index % referenceTiltValues.length;
|
||||||
|
return {
|
||||||
|
'--reference-index': index,
|
||||||
|
'--reference-tilt': `${referenceTiltValues[valueIndex]}deg`,
|
||||||
|
'--reference-x': `${referenceXValues[valueIndex]}px`,
|
||||||
|
'--reference-y': `${referenceYValues[valueIndex]}px`,
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FirstLastFramePicker(props: {
|
||||||
|
accept: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
onSwapFrames?: () => void;
|
||||||
|
}) {
|
||||||
|
const first = frameUploadByRole(props.uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(props.uploads, 'last_frame');
|
||||||
|
const canSwap = Boolean(first && last);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="firstLastFramePicker">
|
||||||
|
<FrameSlot
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={props.disabled}
|
||||||
|
item={first}
|
||||||
|
label="首帧"
|
||||||
|
role="first_frame"
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
/>
|
||||||
|
<button type="button" className="frameSwapButton" aria-label="交换首尾帧" disabled={!canSwap} onClick={props.onSwapFrames}>
|
||||||
|
<Repeat2 size={19} />
|
||||||
|
</button>
|
||||||
|
<FrameSlot
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={props.disabled}
|
||||||
|
item={last}
|
||||||
|
label="尾帧"
|
||||||
|
role="last_frame"
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameSlot(props: {
|
||||||
|
accept: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
item?: PlaygroundUpload;
|
||||||
|
label: string;
|
||||||
|
role: PlaygroundUploadRole;
|
||||||
|
uploading?: boolean;
|
||||||
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const disabled = props.disabled || props.uploading || !props.onFiles;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="frameSlot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="frameSlotButton"
|
||||||
|
data-filled={Boolean(props.item)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={props.item?.name ?? props.label}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{props.item ? <ReferencePreview item={props.item} /> : <Plus size={20} />}
|
||||||
|
<span>{props.label}</span>
|
||||||
|
</button>
|
||||||
|
{props.item && props.onRemove && (
|
||||||
|
<button type="button" className="frameSlotRemove" aria-label={`删除 ${props.label}`} onClick={() => props.onRemove?.(props.item!.id)}>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.currentTarget.files ?? []);
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
props.onFiles?.(files, props.role);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferencePreview(props: { item: PlaygroundUpload }) {
|
||||||
|
if (props.item.kind === 'image') {
|
||||||
|
return <img src={props.item.url} alt="" draggable={false} />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'video') {
|
||||||
|
return <video src={props.item.url} muted playsInline preload="metadata" />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'audio') {
|
||||||
|
return (
|
||||||
|
<span className="mediaReferencePlaceholder">
|
||||||
|
<Music2 size={18} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="mediaReferencePlaceholder">
|
||||||
|
<FileText size={18} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadAttachmentList(props: {
|
||||||
|
message?: string;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
if (!props.uploads.length && !props.message) return null;
|
||||||
|
return (
|
||||||
|
<div className="composerUploadArea">
|
||||||
|
{props.uploads.length > 0 && (
|
||||||
|
<div className="composerUploadList">
|
||||||
|
{props.uploads.map((item) => (
|
||||||
|
<span className="composerUploadChip" key={item.id} title={`${item.name} · ${item.url}`}>
|
||||||
|
{uploadKindIcon(item.kind)}
|
||||||
|
<span>{item.name}</span>
|
||||||
|
<small>{formatFileSize(item.size)}</small>
|
||||||
|
{props.onRemove && (
|
||||||
|
<button type="button" aria-label={`移除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.message && <div className="composerUploadMessage">{props.message}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindIcon(kind: PlaygroundUploadKind) {
|
||||||
|
if (kind === 'image') return <ImageIcon size={14} />;
|
||||||
|
if (kind === 'video') return <Video size={14} />;
|
||||||
|
if (kind === 'audio') return <Music2 size={14} />;
|
||||||
|
return <FileText size={14} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPlaygroundFiles(
|
||||||
|
token: string,
|
||||||
|
files: File[],
|
||||||
|
options: { allowFiles: boolean; allowedKinds?: PlaygroundUploadKind[]; source: string },
|
||||||
|
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
|
||||||
|
const allowedKinds = options.allowedKinds ?? (options.allowFiles ? ['audio', 'file', 'image', 'video'] : ['audio', 'image', 'video']);
|
||||||
|
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
files.forEach((file) => {
|
||||||
|
const kind = acceptedUploadKind(file, options.allowFiles);
|
||||||
|
if (!kind || !allowedKinds.includes(kind)) {
|
||||||
|
warnings.push(options.allowFiles
|
||||||
|
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
|
||||||
|
: `已跳过 ${file.name},当前场景仅支持${allowedUploadKindLabel(allowedKinds)}。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
accepted.push({ file, kind });
|
||||||
|
});
|
||||||
|
if (!accepted.length) return { items: [], warnings };
|
||||||
|
const items = await Promise.all(accepted.map(async ({ file, kind }) => {
|
||||||
|
const response = await uploadFileToStorage(token, file, options.source);
|
||||||
|
const url = uploadResponseUrl(response);
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(`${file.name} 上传成功,但网关没有返回可用文件 URL。`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contentType: file.type || '',
|
||||||
|
id: newLocalId(),
|
||||||
|
kind,
|
||||||
|
name: file.name || '未命名文件',
|
||||||
|
raw: response,
|
||||||
|
size: file.size,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return { items, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptedUploadKind(file: File, allowFiles: boolean): PlaygroundUploadKind | undefined {
|
||||||
|
const mime = file.type.toLowerCase();
|
||||||
|
const extension = fileExtension(file.name);
|
||||||
|
if (mime.startsWith('image/') || imageExtensions.has(extension)) return 'image';
|
||||||
|
if (mime.startsWith('video/') || videoExtensions.has(extension)) return 'video';
|
||||||
|
if (mime.startsWith('audio/') || audioExtensions.has(extension)) return 'audio';
|
||||||
|
if (allowFiles && (documentExtensions.has(extension) || documentMimes.has(mime))) return 'file';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp']);
|
||||||
|
const videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm']);
|
||||||
|
const audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'weba']);
|
||||||
|
const documentExtensions = new Set(['csv', 'doc', 'docx', 'json', 'jsonl', 'md', 'markdown', 'pdf', 'ppt', 'pptx', 'txt', 'xls', 'xlsx', 'yaml', 'yml']);
|
||||||
|
const documentMimes = new Set([
|
||||||
|
'application/json',
|
||||||
|
'application/msword',
|
||||||
|
'application/pdf',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'text/csv',
|
||||||
|
'text/markdown',
|
||||||
|
'text/plain',
|
||||||
|
'text/yaml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function fileExtension(name: string) {
|
||||||
|
const index = name.lastIndexOf('.');
|
||||||
|
return index >= 0 ? name.slice(index + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadResponseUrl(response: Record<string, unknown>) {
|
||||||
|
const data = recordFromUnknown(response.data);
|
||||||
|
const file = recordFromUnknown(response.file);
|
||||||
|
const result = recordFromUnknown(response.result);
|
||||||
|
return firstString(
|
||||||
|
response.url,
|
||||||
|
response.fileUrl,
|
||||||
|
response.file_url,
|
||||||
|
response.objectUrl,
|
||||||
|
response.object_url,
|
||||||
|
response.downloadUrl,
|
||||||
|
response.download_url,
|
||||||
|
data?.url,
|
||||||
|
data?.fileUrl,
|
||||||
|
data?.file_url,
|
||||||
|
file?.url,
|
||||||
|
file?.fileUrl,
|
||||||
|
file?.file_url,
|
||||||
|
result?.url,
|
||||||
|
result?.fileUrl,
|
||||||
|
result?.file_url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAIContentFromPromptAndUploads(prompt: string, uploads: PlaygroundUpload[]): OpenAIChatContentPart[] {
|
||||||
|
const content: OpenAIChatContentPart[] = [];
|
||||||
|
const text = prompt.trim();
|
||||||
|
if (text) {
|
||||||
|
content.push({ type: 'text', text });
|
||||||
|
}
|
||||||
|
uploads.forEach((item) => {
|
||||||
|
const part = openAIContentPartFromUpload(item);
|
||||||
|
if (part) content.push(part);
|
||||||
|
});
|
||||||
|
return content.length ? content : [{ type: 'text', text: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAIContentPartFromUpload(item: PlaygroundUpload): OpenAIChatContentPart | undefined {
|
||||||
|
if (!item.url) return undefined;
|
||||||
|
if (item.kind === 'image') return { type: 'image_url', image_url: { url: item.url } };
|
||||||
|
if (item.kind === 'video') return { type: 'video_url', video_url: { url: item.url } };
|
||||||
|
if (item.kind === 'audio') return { type: 'audio_url', audio_url: { url: item.url } };
|
||||||
|
return { type: 'file_url', file_url: { filename: item.name, url: item.url } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||||
|
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
|
||||||
|
const payload: Record<string, string | string[]> = {};
|
||||||
|
if (mode === 'image') {
|
||||||
|
if (images.length) {
|
||||||
|
payload.image = singleOrMany(images);
|
||||||
|
payload.images = images;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoGenerationContentFromPromptAndUploads(
|
||||||
|
prompt: string,
|
||||||
|
uploads: PlaygroundUpload[],
|
||||||
|
videoMode: PlaygroundVideoCreateMode,
|
||||||
|
): VideoGenerationContent[] {
|
||||||
|
const content: VideoGenerationContent[] = [];
|
||||||
|
const text = prompt.trim();
|
||||||
|
if (text) {
|
||||||
|
content.push({ type: 'text', text });
|
||||||
|
}
|
||||||
|
if (videoMode === 'first_last_frame') {
|
||||||
|
const first = frameUploadByRole(uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(uploads, 'last_frame');
|
||||||
|
if (first?.url) {
|
||||||
|
content.push({ type: 'image_url', role: 'first_frame', image_url: { url: first.url } });
|
||||||
|
}
|
||||||
|
if (last?.url) {
|
||||||
|
content.push({ type: 'image_url', role: 'last_frame', image_url: { url: last.url } });
|
||||||
|
}
|
||||||
|
return content.length ? content : [{ type: 'text', text: '' }];
|
||||||
|
}
|
||||||
|
uploads.forEach((item) => {
|
||||||
|
const part = videoGenerationContentFromUpload(item);
|
||||||
|
if (part) content.push(part);
|
||||||
|
});
|
||||||
|
return content.length ? content : [{ type: 'text', text: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoGenerationContentFromUpload(item: PlaygroundUpload): VideoGenerationContent | undefined {
|
||||||
|
if (!item.url) return undefined;
|
||||||
|
if (item.kind === 'image') {
|
||||||
|
return { type: 'image_url', role: 'reference_image', image_url: { url: item.url } };
|
||||||
|
}
|
||||||
|
if (item.kind === 'video') {
|
||||||
|
return { type: 'video_url', role: 'reference_video', video_url: { url: item.url, refer_type: 'feature' } };
|
||||||
|
}
|
||||||
|
if (item.kind === 'audio') {
|
||||||
|
return { type: 'audio_url', role: 'reference_audio', audio_url: { url: item.url } };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleOrMany(values: string[]) {
|
||||||
|
return values.length === 1 ? values[0] : values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadKindLabel(kind: PlaygroundUploadKind) {
|
||||||
|
if (kind === 'image') return '图片';
|
||||||
|
if (kind === 'video') return '视频';
|
||||||
|
if (kind === 'audio') return '音频';
|
||||||
|
return '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowedUploadKindLabel(kinds: PlaygroundUploadKind[]) {
|
||||||
|
const labels = kinds.map(uploadKindLabel);
|
||||||
|
return labels.length ? labels.join('、') : '当前文件类型';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(size: number) {
|
||||||
|
if (!Number.isFinite(size) || size <= 0) return '';
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaUploadAcceptForMode(mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode) {
|
||||||
|
if (mode === 'image') return imageOnlyUploadAccept;
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') return imageOnlyUploadAccept;
|
||||||
|
return mediaUploadAccept;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowedMediaUploadKinds(mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode): PlaygroundUploadKind[] {
|
||||||
|
if (mode === 'image') return ['image'];
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') return ['image'];
|
||||||
|
if (mode === 'video') return ['audio', 'image', 'video'];
|
||||||
|
return ['audio', 'file', 'image', 'video'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaUploadSummaryMessage(uploads: PlaygroundUpload[], mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode) {
|
||||||
|
if (!uploads.length) return '';
|
||||||
|
const images = uploads.filter((item) => item.kind === 'image').length;
|
||||||
|
const videos = uploads.filter((item) => item.kind === 'video').length;
|
||||||
|
const audios = uploads.filter((item) => item.kind === 'audio').length;
|
||||||
|
const files = uploads.filter((item) => item.kind === 'file').length;
|
||||||
|
if (mode === 'image') {
|
||||||
|
return `已上传 ${images} 张参考图。`;
|
||||||
|
}
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') {
|
||||||
|
const first = frameUploadByRole(uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(uploads, 'last_frame');
|
||||||
|
if (first && last) return '已上传首帧、尾帧参考图。';
|
||||||
|
if (first) return '已上传首帧参考图。';
|
||||||
|
if (last) return '已上传尾帧参考图。';
|
||||||
|
return `已上传 ${images} 张首尾帧参考图。`;
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
|
images ? `${images} 张图片` : '',
|
||||||
|
videos ? `${videos} 个视频` : '',
|
||||||
|
audios ? `${audios} 段音频` : '',
|
||||||
|
files ? `${files} 个文件` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length ? `已上传 ${parts.join('、')}。` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMediaUploadsForMode(
|
||||||
|
current: PlaygroundUpload[],
|
||||||
|
items: PlaygroundUpload[],
|
||||||
|
mode: PlaygroundMode,
|
||||||
|
videoMode: PlaygroundVideoCreateMode,
|
||||||
|
targetRole?: PlaygroundUploadRole,
|
||||||
|
) {
|
||||||
|
if (mode === 'image') {
|
||||||
|
return [...current.filter((item) => item.kind === 'image'), ...items.filter((item) => item.kind === 'image')];
|
||||||
|
}
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') {
|
||||||
|
return mergeFirstLastFrameUploads(current, items, targetRole);
|
||||||
|
}
|
||||||
|
if (mode === 'video') {
|
||||||
|
return [...current, ...items.filter((item) => item.kind === 'image' || item.kind === 'video' || item.kind === 'audio')];
|
||||||
|
}
|
||||||
|
return [...current, ...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
||||||
|
const images = uploads.filter((item) => item.kind === 'image');
|
||||||
|
if (!images.length) return uploads.length ? [] : uploads;
|
||||||
|
const first = frameUploadByRole(images, 'first_frame') ?? images[0];
|
||||||
|
const last = frameUploadByRole(images, 'last_frame') ?? images.find((item) => item.id !== first?.id);
|
||||||
|
const next: PlaygroundUpload[] = [];
|
||||||
|
if (first) next.push({ ...first, role: 'first_frame' });
|
||||||
|
if (last) next.push({ ...last, role: 'last_frame' });
|
||||||
|
return uploadListsEqual(uploads, next) ? uploads : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFirstLastFrameUploads(current: PlaygroundUpload[], items: PlaygroundUpload[], targetRole?: PlaygroundUploadRole) {
|
||||||
|
const incoming = items.filter((item) => item.kind === 'image');
|
||||||
|
let next = normalizeFirstLastFrameUploads(current);
|
||||||
|
if (!incoming.length) return next;
|
||||||
|
const assignUpload = (item: PlaygroundUpload, role: PlaygroundUploadRole) => {
|
||||||
|
next = next.filter((upload) => upload.role !== role);
|
||||||
|
next.push({ ...item, role });
|
||||||
|
};
|
||||||
|
if (targetRole) {
|
||||||
|
assignUpload(incoming[0]!, targetRole);
|
||||||
|
const oppositeRole: PlaygroundUploadRole = targetRole === 'first_frame' ? 'last_frame' : 'first_frame';
|
||||||
|
incoming.slice(1).forEach((item) => {
|
||||||
|
if (!frameUploadByRole(next, oppositeRole)) assignUpload(item, oppositeRole);
|
||||||
|
});
|
||||||
|
return sortFrameUploads(next);
|
||||||
|
}
|
||||||
|
incoming.forEach((item) => {
|
||||||
|
if (!frameUploadByRole(next, 'first_frame')) {
|
||||||
|
assignUpload(item, 'first_frame');
|
||||||
|
} else if (!frameUploadByRole(next, 'last_frame')) {
|
||||||
|
assignUpload(item, 'last_frame');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sortFrameUploads(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swapFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
||||||
|
return sortFrameUploads(uploads.map((item) => {
|
||||||
|
if (item.role === 'first_frame') return { ...item, role: 'last_frame' as const };
|
||||||
|
if (item.role === 'last_frame') return { ...item, role: 'first_frame' as const };
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFrameUploads(uploads: PlaygroundUpload[]) {
|
||||||
|
const first = frameUploadByRole(uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(uploads, 'last_frame');
|
||||||
|
return [first, last].filter((item): item is PlaygroundUpload => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frameUploadByRole(uploads: PlaygroundUpload[], role: PlaygroundUploadRole) {
|
||||||
|
return uploads.find((item) => item.role === role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadListsEqual(left: PlaygroundUpload[], right: PlaygroundUpload[]) {
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
return left.every((item, index) => {
|
||||||
|
const next = right[index];
|
||||||
|
return next && item.id === next.id && item.role === next.role && item.kind === next.kind;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
const text = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function newLocalId() {
|
||||||
|
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ const adminPaths: Record<AdminSection, string> = {
|
|||||||
auditLogs: '/admin/audit-logs',
|
auditLogs: '/admin/audit-logs',
|
||||||
runtime: '/admin/runtime',
|
runtime: '/admin/runtime',
|
||||||
accessRules: '/admin/access-rules',
|
accessRules: '/admin/access-rules',
|
||||||
|
systemSettings: '/admin/system-settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
const docsPaths: Record<ApiDocSection, string> = {
|
const docsPaths: Record<ApiDocSection, string> = {
|
||||||
|
|||||||
@ -1812,6 +1812,121 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileStoragePanel {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageSettingsCard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 380px) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageSettingsCard > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageSettingsCard > label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageToolbar > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageToolbar strong {
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageToolbar span,
|
||||||
|
.fileStorageSettingsCard span,
|
||||||
|
.fileStorageMeta span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageCard {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageCard header,
|
||||||
|
.fileStorageCard footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageCard header > div:nth-child(2) {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageCard strong,
|
||||||
|
.fileStorageCard header span,
|
||||||
|
.fileStorageMeta span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageMeta {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageMeta span {
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageCard footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageDialogBody {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStorageSceneGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.runtimePolicyDialog {
|
.runtimePolicyDialog {
|
||||||
width: min(980px, 100%);
|
width: min(980px, 100%);
|
||||||
}
|
}
|
||||||
@ -1983,6 +2098,7 @@
|
|||||||
.providerCatalogGrid,
|
.providerCatalogGrid,
|
||||||
.baseModelGrid,
|
.baseModelGrid,
|
||||||
.runtimePolicyGrid,
|
.runtimePolicyGrid,
|
||||||
|
.fileStorageGrid,
|
||||||
.platformGrid,
|
.platformGrid,
|
||||||
.accessPermissionGrid,
|
.accessPermissionGrid,
|
||||||
.platformModelChoices {
|
.platformModelChoices {
|
||||||
@ -2023,7 +2139,11 @@
|
|||||||
.platformModelRow,
|
.platformModelRow,
|
||||||
.platformModelToolbar,
|
.platformModelToolbar,
|
||||||
.runtimePolicyGrid,
|
.runtimePolicyGrid,
|
||||||
|
.fileStorageGrid,
|
||||||
|
.fileStorageSettingsCard,
|
||||||
|
.fileStorageSceneGrid,
|
||||||
.runtimePolicyFormBody,
|
.runtimePolicyFormBody,
|
||||||
|
.fileStorageDialogBody,
|
||||||
.runtimePolicyRows,
|
.runtimePolicyRows,
|
||||||
.runnerActionGrid,
|
.runnerActionGrid,
|
||||||
.accessPermissionGrid,
|
.accessPermissionGrid,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,8 @@ export type AdminSection =
|
|||||||
| 'userGroups'
|
| 'userGroups'
|
||||||
| 'auditLogs'
|
| 'auditLogs'
|
||||||
| 'runtime'
|
| 'runtime'
|
||||||
| 'accessRules';
|
| 'accessRules'
|
||||||
|
| 'systemSettings';
|
||||||
|
|
||||||
export interface LoginForm {
|
export interface LoginForm {
|
||||||
account: string;
|
account: string;
|
||||||
|
|||||||
@ -506,6 +506,32 @@ export interface PlayableGatewayApiKey extends GatewayApiKey {
|
|||||||
secret: string;
|
secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayPricingEstimateItem {
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
discountFactor?: number;
|
||||||
|
durationSeconds?: number;
|
||||||
|
durationUnit?: string;
|
||||||
|
durationUnitCount?: number;
|
||||||
|
model?: string;
|
||||||
|
modelAlias?: string;
|
||||||
|
platformId?: string;
|
||||||
|
platformModelId?: string;
|
||||||
|
provider?: string;
|
||||||
|
quantity?: number | string;
|
||||||
|
resourceType?: string;
|
||||||
|
simulated?: boolean;
|
||||||
|
unit?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayPricingEstimate {
|
||||||
|
items: GatewayPricingEstimateItem[];
|
||||||
|
resolver: string;
|
||||||
|
totalAmount?: number;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayWalletAccount {
|
export interface GatewayWalletAccount {
|
||||||
id: string;
|
id: string;
|
||||||
gatewayTenantId?: string;
|
gatewayTenantId?: string;
|
||||||
@ -844,6 +870,46 @@ export interface GatewayNetworkProxyConfig {
|
|||||||
globalHttpProxySource?: string;
|
globalHttpProxySource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileStorageChannel {
|
||||||
|
id: string;
|
||||||
|
channelKey: string;
|
||||||
|
name: string;
|
||||||
|
provider: 'server_main_openapi' | 'aliyun_oss' | 'tencent_cos' | string;
|
||||||
|
uploadUrl?: string;
|
||||||
|
credentialsPreview?: Record<string, unknown>;
|
||||||
|
scenes?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
retryPolicy?: Record<string, unknown>;
|
||||||
|
priority: number;
|
||||||
|
status: 'enabled' | 'disabled' | string;
|
||||||
|
lastError?: string;
|
||||||
|
lastFailedAt?: string;
|
||||||
|
lastSucceededAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileStorageChannelUpsertRequest {
|
||||||
|
channelKey: string;
|
||||||
|
name: string;
|
||||||
|
provider?: 'server_main_openapi' | 'aliyun_oss' | 'tencent_cos' | string;
|
||||||
|
uploadUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
scenes?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
retryPolicy?: Record<string, unknown>;
|
||||||
|
priority?: number;
|
||||||
|
status?: 'enabled' | 'disabled' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileStorageSettings {
|
||||||
|
resultUploadPolicy: 'default' | 'upload_all' | 'upload_none' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileStorageSettingsUpdateRequest {
|
||||||
|
resultUploadPolicy: 'default' | 'upload_all' | 'upload_none' | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayTask {
|
export interface GatewayTask {
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user