fix(media): 对齐 EasyAI 媒体响应与转存策略
统一媒体异步提交和轮询响应,扩展 /ai/result 到当前用户的 Gateway 媒体任务,并保持既有 Gateway 字段兼容。\n\n启用转存但无可用渠道时回退到 24 小时本地静态资源;关闭转存时保留图片、音频等上游 Base64 字段。同步更新文件上传兼容结构、OpenAPI、管理端说明和回归测试。\n\n验证:cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1;gofmt -l 无输出;pnpm nx run web:typecheck。
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func easyAIAsyncMediaRequest(kind string, r *http.Request) bool {
|
||||
if r == nil || !asyncRequest(r) {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case "images.generations",
|
||||
"images.edits",
|
||||
"images.vectorize",
|
||||
"videos.generations",
|
||||
"videos.upscales",
|
||||
"song.generations",
|
||||
"music.generations",
|
||||
"speech.generations",
|
||||
"voice.clone":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func easyAITaskAcceptedResponse(task store.GatewayTask) map[string]any {
|
||||
resultPath := "/api/v1/ai/result/" + task.ID
|
||||
return map[string]any{
|
||||
"status": "submitted",
|
||||
"task_id": task.ID,
|
||||
"taskId": task.ID,
|
||||
"query_url": resultPath,
|
||||
"task": task,
|
||||
"next": map[string]string{
|
||||
"events": "/api/v1/tasks/" + task.ID + "/events",
|
||||
"detail": "/api/v1/tasks/" + task.ID,
|
||||
"result": resultPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func easyAITaskAcceptedResponseWithExtensions(task store.GatewayTask, extensions map[string]any) map[string]any {
|
||||
response := cloneEasyAIMap(extensions)
|
||||
for key, value := range easyAITaskAcceptedResponse(task) {
|
||||
response[key] = value
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func writeEasyAIAsyncError(w http.ResponseWriter, status int, message string, details map[string]any, codes ...string) {
|
||||
code := ""
|
||||
if len(codes) > 0 {
|
||||
code = strings.TrimSpace(codes[0])
|
||||
}
|
||||
errorPayload := map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
}
|
||||
if code != "" {
|
||||
errorPayload["code"] = code
|
||||
}
|
||||
for key, value := range details {
|
||||
errorPayload[key] = value
|
||||
}
|
||||
response := map[string]any{
|
||||
"status": "failed",
|
||||
"message": message,
|
||||
"data": []any{},
|
||||
"error": errorPayload,
|
||||
}
|
||||
if code != "" {
|
||||
response["code"] = code
|
||||
}
|
||||
writeJSON(w, status, response)
|
||||
}
|
||||
|
||||
func easyAISynchronousTaskResponse(task store.GatewayTask, output map[string]any) map[string]any {
|
||||
if task.Kind != "images.vectorize" {
|
||||
return output
|
||||
}
|
||||
response := cloneEasyAIMap(output)
|
||||
response["taskId"] = task.ID
|
||||
response["task_id"] = task.ID
|
||||
response["query_url"] = "/api/v1/ai/result/" + task.ID
|
||||
return response
|
||||
}
|
||||
|
||||
func easyAIFileUploadResponse(upload map[string]any) map[string]any {
|
||||
response := cloneEasyAIMap(upload)
|
||||
response["status"] = "success"
|
||||
response["data"] = cloneEasyAIMap(upload)
|
||||
return response
|
||||
}
|
||||
|
||||
func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
|
||||
sourceResult := cloneEasyAIMap(task.Result)
|
||||
cleanResult := cloneEasyAIMap(sourceResult)
|
||||
delete(cleanResult, "raw")
|
||||
delete(cleanResult, "raw_data")
|
||||
normalizeEasyAIInlineMediaFields(cleanResult)
|
||||
|
||||
data := easyAITaskResultData(task, sourceResult)
|
||||
status := easyAITaskResultStatus(task.Status)
|
||||
if status == "failed" {
|
||||
data = []any{}
|
||||
}
|
||||
cleanResult["data"] = data
|
||||
cleanResult["output_content"] = data
|
||||
|
||||
response := cloneEasyAIMap(cleanResult)
|
||||
response["status"] = status
|
||||
response["task_id"] = task.ID
|
||||
response["taskId"] = task.ID
|
||||
response["query_url"] = "/api/v1/ai/result/" + task.ID
|
||||
response["created"] = task.CreatedAt.UnixMilli()
|
||||
response["data"] = data
|
||||
response["output_content"] = data
|
||||
response["output"] = easyAIOutputURLs(data)
|
||||
response["result"] = cleanResult
|
||||
|
||||
upstreamTaskID := firstNonEmpty(
|
||||
easyAIString(cleanResult["upstream_task_id"]),
|
||||
task.RemoteTaskID,
|
||||
)
|
||||
if upstreamTaskID != "" {
|
||||
response["upstream_task_id"] = upstreamTaskID
|
||||
}
|
||||
if len(task.Usage) > 0 && response["usage"] == nil {
|
||||
response["usage"] = task.Usage
|
||||
}
|
||||
|
||||
cancelState := runner.DescribeTaskCancellation(task)
|
||||
response["cancellable"] = cancelState.Cancellable
|
||||
response["submitted"] = cancelState.Submitted
|
||||
|
||||
message := firstNonEmpty(
|
||||
easyAIString(cleanResult["message"]),
|
||||
task.ErrorMessage,
|
||||
task.Error,
|
||||
task.Message,
|
||||
)
|
||||
if message == "" {
|
||||
switch status {
|
||||
case "submitted":
|
||||
message = "任务已提交,请稍后查看结果"
|
||||
case "process":
|
||||
message = "任务处理中"
|
||||
case "failed":
|
||||
message = "任务执行失败"
|
||||
}
|
||||
}
|
||||
if message != "" {
|
||||
response["message"] = message
|
||||
}
|
||||
if code := firstNonEmpty(task.ErrorCode, easyAIString(cleanResult["code"])); code != "" {
|
||||
response["code"] = code
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func easyAITaskResultStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "success"
|
||||
case "failed", "cancelled", "canceled", "rejected":
|
||||
return "failed"
|
||||
case "running", "processing", "in_progress":
|
||||
return "process"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func easyAITaskResultData(task store.GatewayTask, result map[string]any) []any {
|
||||
for _, source := range easyAIResultSources(result) {
|
||||
if items := easyAIOutputArray(source); len(items) > 0 {
|
||||
return normalizeEasyAIOutputItems(task, items)
|
||||
}
|
||||
}
|
||||
if item := easyAIOutputMap(result); len(item) > 0 {
|
||||
normalizeEasyAIInlineMediaFields(item)
|
||||
if easyAIOutputURL(item) != "" ||
|
||||
easyAIOutputHasInlineMedia(item) ||
|
||||
(strings.EqualFold(easyAIString(item["type"]), "text") && (item["text"] != nil || item["content"] != nil)) {
|
||||
return normalizeEasyAIOutputItems(task, []any{item})
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
func easyAIResultSources(result map[string]any) []any {
|
||||
sources := []any{
|
||||
result["data"],
|
||||
result["output_content"],
|
||||
easyAIStructuredOutput(result["content"]),
|
||||
result["images"],
|
||||
result["videos"],
|
||||
result["audios"],
|
||||
result["files"],
|
||||
result["output"],
|
||||
}
|
||||
if raw, ok := result["raw"].(map[string]any); ok {
|
||||
sources = append(sources,
|
||||
raw["data"],
|
||||
raw["output_content"],
|
||||
easyAIStructuredOutput(raw["content"]),
|
||||
raw["images"],
|
||||
raw["videos"],
|
||||
raw["audios"],
|
||||
raw["files"],
|
||||
raw["output"],
|
||||
)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func easyAIStructuredOutput(value any) any {
|
||||
switch value.(type) {
|
||||
case []any, []string, map[string]any:
|
||||
return value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputArray(value any) []any {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return typed
|
||||
case []string:
|
||||
items := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
case map[string]any, string:
|
||||
return []any{typed}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEasyAIOutputItems(task store.GatewayTask, items []any) []any {
|
||||
normalized := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
output := easyAIOutputMap(item)
|
||||
if len(output) == 0 {
|
||||
continue
|
||||
}
|
||||
delete(output, "raw_data")
|
||||
normalizeEasyAIInlineMediaFields(output)
|
||||
|
||||
mediaURL := easyAIOutputURL(output)
|
||||
if mediaURL != "" {
|
||||
output["url"] = mediaURL
|
||||
}
|
||||
if strings.TrimSpace(easyAIString(output["type"])) == "" {
|
||||
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
|
||||
output["type"] = outputType
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, output)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func easyAIOutputMap(value any) map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneEasyAIMap(typed)
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "data:") {
|
||||
return map[string]any{"url": trimmed}
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") ||
|
||||
strings.HasPrefix(strings.ToLower(trimmed), "http://") ||
|
||||
strings.HasPrefix(strings.ToLower(trimmed), "https://") {
|
||||
return map[string]any{"url": trimmed}
|
||||
}
|
||||
return map[string]any{"type": "text", "content": trimmed}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputURL(item map[string]any) string {
|
||||
for _, key := range []string{
|
||||
"url",
|
||||
"generated_url",
|
||||
"generatedUrl",
|
||||
"output_url",
|
||||
"outputUrl",
|
||||
"download_url",
|
||||
"downloadUrl",
|
||||
"image_url",
|
||||
"video_url",
|
||||
"audio_url",
|
||||
} {
|
||||
if value := easyAINestedURL(item[key]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func easyAINestedURL(value any) string {
|
||||
var candidate string
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
candidate = strings.TrimSpace(typed)
|
||||
case map[string]any:
|
||||
candidate = strings.TrimSpace(easyAIString(typed["url"]))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(candidate), "data:") {
|
||||
return ""
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func normalizeEasyAIInlineMediaFields(item map[string]any) {
|
||||
for _, key := range []string{
|
||||
"url",
|
||||
"generated_url",
|
||||
"generatedUrl",
|
||||
"output_url",
|
||||
"outputUrl",
|
||||
"download_url",
|
||||
"downloadUrl",
|
||||
"image_url",
|
||||
"video_url",
|
||||
"audio_url",
|
||||
} {
|
||||
value := easyAINestedURLAllowData(item[key])
|
||||
contentType, payload, ok := easyAIBase64DataURL(value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
if easyAIString(item["b64_json"]) == "" {
|
||||
item["b64_json"] = payload
|
||||
}
|
||||
} else if easyAIString(item["content"]) == "" {
|
||||
item["content"] = value
|
||||
}
|
||||
if easyAIString(item["mime_type"]) == "" {
|
||||
item["mime_type"] = contentType
|
||||
}
|
||||
delete(item, key)
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIBase64DataURL(value string) (string, string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if !strings.HasPrefix(strings.ToLower(value), "data:") {
|
||||
return "", "", false
|
||||
}
|
||||
meta, payload, ok := strings.Cut(value, ",")
|
||||
if !ok || strings.TrimSpace(payload) == "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(meta[5:]), ";")
|
||||
if len(parts) < 2 || !strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "base64") {
|
||||
return "", "", false
|
||||
}
|
||||
contentType := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
if !strings.HasPrefix(contentType, "image/") &&
|
||||
!strings.HasPrefix(contentType, "audio/") &&
|
||||
!strings.HasPrefix(contentType, "video/") &&
|
||||
!strings.HasPrefix(contentType, "application/") {
|
||||
return "", "", false
|
||||
}
|
||||
return contentType, strings.TrimSpace(payload), true
|
||||
}
|
||||
|
||||
func easyAIOutputHasInlineMedia(item map[string]any) bool {
|
||||
if easyAIString(item["b64_json"]) != "" {
|
||||
return true
|
||||
}
|
||||
content := strings.ToLower(easyAIString(item["content"]))
|
||||
return strings.HasPrefix(content, "data:") && strings.Contains(content, ";base64,")
|
||||
}
|
||||
|
||||
func easyAINestedURLAllowData(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case map[string]any:
|
||||
return strings.TrimSpace(easyAIString(typed["url"]))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputType(task store.GatewayTask, item map[string]any, mediaURL string) string {
|
||||
kind := strings.ToLower(strings.TrimSpace(task.Kind))
|
||||
switch {
|
||||
case kind == "images.vectorize":
|
||||
format := strings.ToLower(firstNonEmpty(
|
||||
easyAIString(item["format"]),
|
||||
easyAIString(task.Request["format"]),
|
||||
))
|
||||
if format == "svg" || format == "png" {
|
||||
return "image"
|
||||
}
|
||||
if format != "" {
|
||||
return "file"
|
||||
}
|
||||
case strings.HasPrefix(kind, "images."):
|
||||
return "image"
|
||||
case strings.HasPrefix(kind, "videos.") || kind == "video.upscale":
|
||||
return "video"
|
||||
case kind == "song.generations" || kind == "music.generations" || kind == "speech.generations":
|
||||
return "audio"
|
||||
}
|
||||
|
||||
lowerURL := strings.ToLower(mediaURL)
|
||||
if index := strings.IndexAny(lowerURL, "?#"); index >= 0 {
|
||||
lowerURL = lowerURL[:index]
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(lowerURL, ".svg"),
|
||||
strings.HasSuffix(lowerURL, ".png"),
|
||||
strings.HasSuffix(lowerURL, ".jpg"),
|
||||
strings.HasSuffix(lowerURL, ".jpeg"),
|
||||
strings.HasSuffix(lowerURL, ".webp"),
|
||||
strings.HasSuffix(lowerURL, ".gif"):
|
||||
return "image"
|
||||
case strings.HasSuffix(lowerURL, ".mp4"),
|
||||
strings.HasSuffix(lowerURL, ".mov"),
|
||||
strings.HasSuffix(lowerURL, ".webm"):
|
||||
return "video"
|
||||
case strings.HasSuffix(lowerURL, ".mp3"),
|
||||
strings.HasSuffix(lowerURL, ".wav"),
|
||||
strings.HasSuffix(lowerURL, ".m4a"),
|
||||
strings.HasSuffix(lowerURL, ".aac"),
|
||||
strings.HasSuffix(lowerURL, ".flac"):
|
||||
return "audio"
|
||||
case mediaURL != "":
|
||||
return "file"
|
||||
case item["text"] != nil || item["content"] != nil:
|
||||
return "text"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputURLs(data []any) []string {
|
||||
output := make([]string, 0, len(data))
|
||||
for _, item := range data {
|
||||
if typed, ok := item.(map[string]any); ok {
|
||||
if mediaURL := easyAIOutputURL(typed); mediaURL != "" {
|
||||
output = append(output, mediaURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func easyAIString(value any) string {
|
||||
typed, _ := value.(string)
|
||||
return strings.TrimSpace(typed)
|
||||
}
|
||||
|
||||
func cloneEasyAIMap(source map[string]any) map[string]any {
|
||||
if len(source) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user