统一媒体异步提交和轮询响应,扩展 /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。
490 lines
12 KiB
Go
490 lines
12 KiB
Go
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
|
|
}
|