feat: add file storage settings and uploads
This commit is contained in:
parent
0d0d0b9115
commit
fc5dfd6bc5
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
|
||||||
|
}
|
||||||
@ -102,6 +102,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 +129,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 +142,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))
|
||||||
}
|
}
|
||||||
|
|||||||
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 ""
|
||||||
|
}
|
||||||
@ -481,7 +481,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(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
187
apps/api/internal/runner/upload_test.go
Normal file
187
apps/api/internal/runner/upload_test.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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 TestGeneratedAssetDecisionSkipsAllWhenPolicyUploadNone(t *testing.T) {
|
||||||
|
item := map[string]any{
|
||||||
|
"b64_json": base64.StdEncoding.EncodeToString([]byte("inline image")),
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := generatedAssetDecisionForItem("images.generations", item, generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if decision.Inline != nil || decision.URL != nil || len(decision.StripKeys) != 0 {
|
||||||
|
t.Fatalf("upload_none should keep the result unchanged: %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedAssetUploadPolicyFromName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policyName string
|
||||||
|
want generatedAssetUploadPolicy
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
policyName: store.FileStorageResultUploadPolicyDefault,
|
||||||
|
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload all",
|
||||||
|
policyName: store.FileStorageResultUploadPolicyUploadAll,
|
||||||
|
want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload none",
|
||||||
|
policyName: store.FileStorageResultUploadPolicyUploadNone,
|
||||||
|
want: generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
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,21 @@ 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,
|
||||||
getHealth,
|
getHealth,
|
||||||
|
listFileStorageChannels,
|
||||||
|
getFileStorageSettings,
|
||||||
getNetworkProxyConfig,
|
getNetworkProxyConfig,
|
||||||
getRunnerPolicy,
|
getRunnerPolicy,
|
||||||
getWalletSummary,
|
getWalletSummary,
|
||||||
@ -78,6 +86,8 @@ import {
|
|||||||
setUserWalletBalance,
|
setUserWalletBalance,
|
||||||
type HealthResponse,
|
type HealthResponse,
|
||||||
updateAccessRule,
|
updateAccessRule,
|
||||||
|
updateFileStorageChannel,
|
||||||
|
updateFileStorageSettings,
|
||||||
updateGatewayUser,
|
updateGatewayUser,
|
||||||
updatePlatform,
|
updatePlatform,
|
||||||
updatePlatformDynamicPriority,
|
updatePlatformDynamicPriority,
|
||||||
@ -135,6 +145,8 @@ type DataKey =
|
|||||||
| 'playgroundModels'
|
| 'playgroundModels'
|
||||||
| 'modelCatalog'
|
| 'modelCatalog'
|
||||||
| 'networkProxyConfig'
|
| 'networkProxyConfig'
|
||||||
|
| 'fileStorageChannels'
|
||||||
|
| 'fileStorageSettings'
|
||||||
| 'platforms'
|
| 'platforms'
|
||||||
| 'models'
|
| 'models'
|
||||||
| 'providers'
|
| 'providers'
|
||||||
@ -179,6 +191,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[]>([]);
|
||||||
@ -296,6 +310,8 @@ export function App() {
|
|||||||
auditLogs,
|
auditLogs,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
baseModels,
|
baseModels,
|
||||||
|
fileStorageChannels,
|
||||||
|
fileStorageSettings,
|
||||||
modelCatalog,
|
modelCatalog,
|
||||||
models,
|
models,
|
||||||
networkProxyConfig,
|
networkProxyConfig,
|
||||||
@ -315,7 +331,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);
|
||||||
@ -388,6 +404,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 +840,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('');
|
||||||
@ -867,6 +936,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([]);
|
||||||
@ -1039,6 +1109,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 +1125,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 +1340,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,
|
||||||
@ -601,10 +605,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 +633,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,
|
||||||
@ -636,6 +666,9 @@ export async function createVideoGenerationTask(
|
|||||||
token: string,
|
token: string,
|
||||||
input: {
|
input: {
|
||||||
audio?: boolean;
|
audio?: boolean;
|
||||||
|
audioUrl?: string | string[];
|
||||||
|
audio_url?: string | string[];
|
||||||
|
content?: Array<Record<string, unknown>>;
|
||||||
model: string;
|
model: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
@ -643,12 +676,24 @@ export async function createVideoGenerationTask(
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
duration_seconds?: number;
|
duration_seconds?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
image?: string | string[];
|
||||||
|
imageUrl?: string | string[];
|
||||||
|
image_url?: string | string[];
|
||||||
|
imageUrls?: string[];
|
||||||
|
image_urls?: string[];
|
||||||
n?: number;
|
n?: number;
|
||||||
output_audio?: boolean;
|
output_audio?: boolean;
|
||||||
|
referenceAudio?: string | string[];
|
||||||
|
referenceVideo?: string | string[];
|
||||||
|
reference_audio?: string | string[];
|
||||||
|
reference_image?: string | string[];
|
||||||
|
reference_video?: string | string[];
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
runMode?: string;
|
runMode?: string;
|
||||||
simulation?: boolean;
|
simulation?: boolean;
|
||||||
size?: string;
|
size?: string;
|
||||||
|
videoUrl?: string | string[];
|
||||||
|
video_url?: string | string[];
|
||||||
width?: number;
|
width?: number;
|
||||||
},
|
},
|
||||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
@ -660,6 +705,41 @@ 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>,
|
||||||
@ -758,6 +838,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>
|
||||||
|
|
||||||
|
|||||||
@ -19,9 +19,9 @@ import { code } from '@streamdown/code';
|
|||||||
import { math } from '@streamdown/math';
|
import { math } from '@streamdown/math';
|
||||||
import { mermaid } from '@streamdown/mermaid';
|
import { mermaid } from '@streamdown/mermaid';
|
||||||
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||||
import { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
|
import { Bot, ChevronDown, FileText, Image as ImageIcon, LoaderCircle, MessageSquarePlus, Music2, Paperclip, Send, Sparkles, Video, X } from 'lucide-react';
|
||||||
import { Badge, Button, Select, Textarea } from '../components/ui';
|
import { Badge, Button, Select, Textarea } from '../components/ui';
|
||||||
import { GatewayApiError, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending } from '../api';
|
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending, uploadFileToStorage } from '../api';
|
||||||
import type { PlaygroundMode } from '../types';
|
import type { PlaygroundMode } from '../types';
|
||||||
import {
|
import {
|
||||||
defaultMediaGenerationSettings,
|
defaultMediaGenerationSettings,
|
||||||
@ -58,6 +58,18 @@ interface ModelOption {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
|
||||||
|
|
||||||
|
interface PlaygroundUpload {
|
||||||
|
contentType: string;
|
||||||
|
id: string;
|
||||||
|
kind: PlaygroundUploadKind;
|
||||||
|
name: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
const modeOptions: Array<{ description: string; icon: ReactNode; label: string; value: PlaygroundMode }> = [
|
const modeOptions: Array<{ description: string; icon: ReactNode; label: string; value: PlaygroundMode }> = [
|
||||||
{ value: 'chat', label: '大模型对话', description: '对话、推理、结构化输出', icon: <Bot size={16} /> },
|
{ value: 'chat', label: '大模型对话', description: '对话、推理、结构化输出', icon: <Bot size={16} /> },
|
||||||
{ value: 'image', label: '图像生成', description: '文生图、图像编辑参数预览', icon: <ImageIcon size={16} /> },
|
{ value: 'image', label: '图像生成', description: '文生图、图像编辑参数预览', icon: <ImageIcon size={16} /> },
|
||||||
@ -82,6 +94,35 @@ const quickPrompts: Record<PlaygroundMode, string[]> = {
|
|||||||
video: ['5 秒运镜', '首帧转视频', '宣传短片'],
|
video: ['5 秒运镜', '首帧转视频', '宣传短片'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mediaUploadAccept = 'image/*,video/*,audio/*';
|
||||||
|
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(',');
|
||||||
|
|
||||||
const publicWorks = [
|
const publicWorks = [
|
||||||
{ title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
|
{ title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
|
||||||
{ title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
|
{ title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
|
||||||
@ -119,13 +160,17 @@ export function PlaygroundPage(props: {
|
|||||||
const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings);
|
const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings);
|
||||||
const [mediaRuns, setMediaRuns] = useState<MediaGenerationRun[]>(readStoredMediaRuns);
|
const [mediaRuns, setMediaRuns] = useState<MediaGenerationRun[]>(readStoredMediaRuns);
|
||||||
const [mediaMessage, setMediaMessage] = useState('');
|
const [mediaMessage, setMediaMessage] = useState('');
|
||||||
|
const [mediaUploadMessage, setMediaUploadMessage] = useState('');
|
||||||
|
const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
|
||||||
|
const [mediaUploading, setMediaUploading] = useState(false);
|
||||||
const isMountedRef = useRef(false);
|
const isMountedRef = useRef(false);
|
||||||
const pendingMediaModelRef = useRef('');
|
const pendingMediaModelRef = useRef('');
|
||||||
const resumedTaskIdsRef = useRef(new Set<string>());
|
const resumedTaskIdsRef = useRef(new Set<string>());
|
||||||
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
||||||
|
const effectiveImageHasReference = imageHasReference || (props.mode === 'image' && mediaUploads.some((item) => item.kind === 'image'));
|
||||||
const modelOptions = useMemo(
|
const modelOptions = useMemo(
|
||||||
() => buildModelOptions(filterModelsForMode(props.models, props.mode, imageHasReference, videoMode)),
|
() => buildModelOptions(filterModelsForMode(props.models, props.mode, effectiveImageHasReference, videoMode)),
|
||||||
[imageHasReference, props.mode, props.models, videoMode],
|
[effectiveImageHasReference, props.mode, props.models, videoMode],
|
||||||
);
|
);
|
||||||
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
||||||
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
|
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
|
||||||
@ -170,6 +215,38 @@ export function PlaygroundPage(props: {
|
|||||||
writeStoredMediaRuns(mediaRuns);
|
writeStoredMediaRuns(mediaRuns);
|
||||||
}, [mediaRuns]);
|
}, [mediaRuns]);
|
||||||
|
|
||||||
|
async function uploadMediaFiles(files: File[]) {
|
||||||
|
if (!files.length) return;
|
||||||
|
const credential = activeApiKeySecret || props.token;
|
||||||
|
if (!props.token) {
|
||||||
|
props.onLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!credential) {
|
||||||
|
setMediaUploadMessage('请选择可用于测试的 API Key 后再上传。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMediaUploading(true);
|
||||||
|
setMediaUploadMessage('');
|
||||||
|
try {
|
||||||
|
const { items, warnings } = await uploadPlaygroundFiles(credential, files, {
|
||||||
|
allowFiles: false,
|
||||||
|
source: `ai-gateway-playground-${props.mode}`,
|
||||||
|
});
|
||||||
|
if (items.length) {
|
||||||
|
setMediaUploads((current) => [...current, ...items]);
|
||||||
|
if (props.mode === 'image' && items.some((item) => item.kind === 'image')) {
|
||||||
|
setImageHasReference(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMediaUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个参考素材。` : ''));
|
||||||
|
} catch (err) {
|
||||||
|
setMediaUploadMessage(err instanceof Error ? err.message : '文件上传失败');
|
||||||
|
} finally {
|
||||||
|
setMediaUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const credential = activeApiKeySecret || props.token;
|
const credential = activeApiKeySecret || props.token;
|
||||||
if (!credential) return;
|
if (!credential) return;
|
||||||
@ -227,6 +304,7 @@ export function PlaygroundPage(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localId = newLocalId();
|
const localId = newLocalId();
|
||||||
|
const runUploads = overrides ? [] : mediaUploads;
|
||||||
const modelLabel = modelOptions.find((item) => item.value === runModel)?.label ?? runModel;
|
const modelLabel = modelOptions.find((item) => item.value === runModel)?.label ?? runModel;
|
||||||
const run: MediaGenerationRun = {
|
const run: MediaGenerationRun = {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@ -242,15 +320,24 @@ export function PlaygroundPage(props: {
|
|||||||
setMediaRuns((current) => [...current, run]);
|
setMediaRuns((current) => [...current, run]);
|
||||||
setMediaMessage('');
|
setMediaMessage('');
|
||||||
try {
|
try {
|
||||||
|
const uploadPayload = mediaUploadRequestPayload(runUploads, runMode);
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
model: runModel,
|
model: runModel,
|
||||||
prompt: trimmedPrompt,
|
prompt: promptWithUploadSummary(trimmedPrompt, runUploads),
|
||||||
...mediaRequestPayload(runSettings, runMode),
|
...mediaRequestPayload(runSettings, runMode),
|
||||||
|
...uploadPayload,
|
||||||
};
|
};
|
||||||
const response = runMode === 'video'
|
const response = runMode === 'video'
|
||||||
? await createVideoGenerationTask(credential, requestPayload)
|
? await createVideoGenerationTask(credential, requestPayload)
|
||||||
: await createImageGenerationTask(credential, requestPayload);
|
: runUploads.some((item) => item.kind === 'image')
|
||||||
|
? await createImageEditTask(credential, requestPayload)
|
||||||
|
: await createImageGenerationTask(credential, requestPayload);
|
||||||
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
|
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
|
||||||
|
if (!overrides) {
|
||||||
|
setMediaUploads([]);
|
||||||
|
setMediaUploadMessage('');
|
||||||
|
setImageHasReference(false);
|
||||||
|
}
|
||||||
void pollMediaRunUntilSettled(credential, localId, response.task);
|
void pollMediaRunUntilSettled(credential, localId, response.task);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
|
const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
|
||||||
@ -325,9 +412,13 @@ export function PlaygroundPage(props: {
|
|||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
selectedApiKeyId={activeApiKeyId}
|
selectedApiKeyId={activeApiKeyId}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
imageHasReference={imageHasReference}
|
imageHasReference={effectiveImageHasReference}
|
||||||
mediaSettings={mediaSettings}
|
mediaSettings={mediaSettings}
|
||||||
mediaCapabilities={mediaCapabilities}
|
mediaCapabilities={mediaCapabilities}
|
||||||
|
uploadAccept={mediaUploadAccept}
|
||||||
|
uploadMessage={mediaUploadMessage}
|
||||||
|
uploads={mediaUploads}
|
||||||
|
uploading={mediaUploading}
|
||||||
videoMode={videoMode}
|
videoMode={videoMode}
|
||||||
onApiKeyChange={props.onApiKeyChange}
|
onApiKeyChange={props.onApiKeyChange}
|
||||||
onCreateApiKey={props.onCreateApiKey}
|
onCreateApiKey={props.onCreateApiKey}
|
||||||
@ -336,7 +427,15 @@ export function PlaygroundPage(props: {
|
|||||||
onModeChange={props.onModeChange}
|
onModeChange={props.onModeChange}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
onPromptChange={setPrompt}
|
onPromptChange={setPrompt}
|
||||||
|
onRemoveUpload={(id) => setMediaUploads((current) => {
|
||||||
|
const next = current.filter((item) => item.id !== id);
|
||||||
|
if (!next.some((item) => item.kind === 'image')) {
|
||||||
|
setImageHasReference(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
onSubmit={() => void submitMediaTask()}
|
onSubmit={() => void submitMediaTask()}
|
||||||
|
onUploadFiles={(files) => void uploadMediaFiles(files)}
|
||||||
onVideoModeChange={setVideoMode}
|
onVideoModeChange={setVideoMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -491,6 +590,42 @@ function AssistantChatPlayground(props: {
|
|||||||
const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
|
const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
|
||||||
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
||||||
const initialMessages = useMemo(() => readStoredChatMessages(), []);
|
const initialMessages = useMemo(() => readStoredChatMessages(), []);
|
||||||
|
const [chatUploadMessage, setChatUploadMessage] = useState('');
|
||||||
|
const [chatUploads, setChatUploads] = useState<PlaygroundUpload[]>([]);
|
||||||
|
const [chatUploading, setChatUploading] = useState(false);
|
||||||
|
const chatUploadsRef = useRef(chatUploads);
|
||||||
|
useEffect(() => {
|
||||||
|
chatUploadsRef.current = chatUploads;
|
||||||
|
}, [chatUploads]);
|
||||||
|
|
||||||
|
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 uploadPlaygroundFiles(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>(() => ({
|
const adapter = useMemo<ChatModelAdapter>(() => ({
|
||||||
async *run({ abortSignal, messages }) {
|
async *run({ abortSignal, messages }) {
|
||||||
if (!props.token) {
|
if (!props.token) {
|
||||||
@ -503,11 +638,17 @@ function AssistantChatPlayground(props: {
|
|||||||
if (!props.selectedModel) {
|
if (!props.selectedModel) {
|
||||||
throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
|
throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
|
||||||
}
|
}
|
||||||
|
const requestUploads = chatUploadsRef.current;
|
||||||
|
if (requestUploads.length) {
|
||||||
|
chatUploadsRef.current = [];
|
||||||
|
setChatUploads([]);
|
||||||
|
setChatUploadMessage('');
|
||||||
|
}
|
||||||
let text = '';
|
let text = '';
|
||||||
for await (const delta of streamChatCompletionText(
|
for await (const delta of streamChatCompletionText(
|
||||||
activeApiKeySecret,
|
activeApiKeySecret,
|
||||||
{
|
{
|
||||||
messages: toGatewayChatMessages(messages),
|
messages: toGatewayChatMessages(messages, requestUploads),
|
||||||
model: props.selectedModel,
|
model: props.selectedModel,
|
||||||
},
|
},
|
||||||
abortSignal,
|
abortSignal,
|
||||||
@ -541,10 +682,16 @@ function AssistantChatPlayground(props: {
|
|||||||
selectedModel={props.selectedModel}
|
selectedModel={props.selectedModel}
|
||||||
token={props.token}
|
token={props.token}
|
||||||
activeApiKeySecret={activeApiKeySecret}
|
activeApiKeySecret={activeApiKeySecret}
|
||||||
|
uploadAccept={chatUploadAccept}
|
||||||
|
uploadMessage={chatUploadMessage}
|
||||||
|
uploads={chatUploads}
|
||||||
|
uploading={chatUploading}
|
||||||
onApiKeyChange={props.onApiKeyChange}
|
onApiKeyChange={props.onApiKeyChange}
|
||||||
onCreateApiKey={props.onCreateApiKey}
|
onCreateApiKey={props.onCreateApiKey}
|
||||||
onModeChange={props.onModeChange}
|
onModeChange={props.onModeChange}
|
||||||
onModelChange={props.onModelChange}
|
onModelChange={props.onModelChange}
|
||||||
|
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
|
||||||
|
onUploadFiles={(files) => void uploadChatFiles(files)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ThreadPrimitive.Empty>
|
</ThreadPrimitive.Empty>
|
||||||
@ -573,10 +720,16 @@ function AssistantChatPlayground(props: {
|
|||||||
placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
|
placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
|
||||||
selectedApiKeyId={activeApiKeyId}
|
selectedApiKeyId={activeApiKeyId}
|
||||||
selectedModel={props.selectedModel}
|
selectedModel={props.selectedModel}
|
||||||
|
uploadAccept={chatUploadAccept}
|
||||||
|
uploadMessage={chatUploadMessage}
|
||||||
|
uploads={chatUploads}
|
||||||
|
uploading={chatUploading}
|
||||||
onApiKeyChange={props.onApiKeyChange}
|
onApiKeyChange={props.onApiKeyChange}
|
||||||
onCreateApiKey={props.onCreateApiKey}
|
onCreateApiKey={props.onCreateApiKey}
|
||||||
onModeChange={props.onModeChange}
|
onModeChange={props.onModeChange}
|
||||||
onModelChange={props.onModelChange}
|
onModelChange={props.onModelChange}
|
||||||
|
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
|
||||||
|
onUploadFiles={(files) => void uploadChatFiles(files)}
|
||||||
/>
|
/>
|
||||||
</ThreadPrimitive.ViewportFooter>
|
</ThreadPrimitive.ViewportFooter>
|
||||||
</ThreadPrimitive.Viewport>
|
</ThreadPrimitive.Viewport>
|
||||||
@ -645,10 +798,16 @@ function AssistantEmptyState(props: {
|
|||||||
selectedApiKeyId: string;
|
selectedApiKeyId: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
uploadAccept: string;
|
||||||
|
uploadMessage: string;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading: boolean;
|
||||||
onApiKeyChange: (apiKeyId: string) => void;
|
onApiKeyChange: (apiKeyId: string) => void;
|
||||||
onCreateApiKey: () => void;
|
onCreateApiKey: () => void;
|
||||||
onModeChange: (mode: PlaygroundMode) => void;
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
onModelChange: (value: string) => void;
|
onModelChange: (value: string) => void;
|
||||||
|
onRemoveUpload: (id: string) => void;
|
||||||
|
onUploadFiles: (files: File[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const activeMode = modeOptions.find((item) => item.value === 'chat') ?? modeOptions[0];
|
const activeMode = modeOptions.find((item) => item.value === 'chat') ?? modeOptions[0];
|
||||||
const placeholder = props.canRun ? placeholderByMode.chat : assistantPlaceholder(props.token, props.selectedModel, props.activeApiKeySecret);
|
const placeholder = props.canRun ? placeholderByMode.chat : assistantPlaceholder(props.token, props.selectedModel, props.activeApiKeySecret);
|
||||||
@ -666,10 +825,16 @@ function AssistantEmptyState(props: {
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
selectedApiKeyId={props.selectedApiKeyId}
|
selectedApiKeyId={props.selectedApiKeyId}
|
||||||
selectedModel={props.selectedModel}
|
selectedModel={props.selectedModel}
|
||||||
|
uploadAccept={props.uploadAccept}
|
||||||
|
uploadMessage={props.uploadMessage}
|
||||||
|
uploads={props.uploads}
|
||||||
|
uploading={props.uploading}
|
||||||
onApiKeyChange={props.onApiKeyChange}
|
onApiKeyChange={props.onApiKeyChange}
|
||||||
onCreateApiKey={props.onCreateApiKey}
|
onCreateApiKey={props.onCreateApiKey}
|
||||||
onModeChange={props.onModeChange}
|
onModeChange={props.onModeChange}
|
||||||
onModelChange={props.onModelChange}
|
onModelChange={props.onModelChange}
|
||||||
|
onRemoveUpload={props.onRemoveUpload}
|
||||||
|
onUploadFiles={props.onUploadFiles}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -685,24 +850,41 @@ function AssistantChatComposer(props: {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
selectedApiKeyId: string;
|
selectedApiKeyId: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
|
uploadAccept?: string;
|
||||||
|
uploadMessage?: string;
|
||||||
|
uploads?: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
onApiKeyChange: (apiKeyId: string) => void;
|
onApiKeyChange: (apiKeyId: string) => void;
|
||||||
onCreateApiKey: () => void;
|
onCreateApiKey: () => void;
|
||||||
onModeChange: (mode: PlaygroundMode) => void;
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
onModelChange: (value: string) => void;
|
onModelChange: (value: string) => void;
|
||||||
|
onRemoveUpload?: (id: string) => void;
|
||||||
|
onUploadFiles?: (files: File[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const className = ['playgroundComposer', 'assistantChatComposer', props.docked ? 'assistantDockComposer' : 'assistantEmptyComposer'].join(' ');
|
const className = ['playgroundComposer', 'assistantChatComposer', props.docked ? 'assistantDockComposer' : 'assistantEmptyComposer'].join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className={className}>
|
<ComposerPrimitive.Root className={className}>
|
||||||
<div className="composerBody">
|
<div className="composerBody">
|
||||||
<button type="button" className="composerUpload" aria-label="上传参考" disabled>
|
<ComposerUploadButton
|
||||||
<Paperclip size={18} />
|
accept={props.uploadAccept ?? chatUploadAccept}
|
||||||
</button>
|
active={Boolean(props.uploads?.length)}
|
||||||
<ComposerPrimitive.Input
|
disabled={!props.canRun || !props.onUploadFiles}
|
||||||
className="assistantEmptyInput"
|
uploading={props.uploading}
|
||||||
disabled={!props.canRun}
|
onFiles={props.onUploadFiles}
|
||||||
placeholder={props.placeholder}
|
|
||||||
/>
|
/>
|
||||||
|
<div className="composerInputStack">
|
||||||
|
<ComposerPrimitive.Input
|
||||||
|
className="assistantEmptyInput"
|
||||||
|
disabled={!props.canRun}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
/>
|
||||||
|
<UploadAttachmentList
|
||||||
|
message={props.uploadMessage}
|
||||||
|
uploads={props.uploads ?? []}
|
||||||
|
onRemove={props.onRemoveUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="composerFooter">
|
<div className="composerFooter">
|
||||||
<Select value="chat" onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
|
<Select value="chat" onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
|
||||||
@ -780,14 +962,25 @@ function AssistantMarkdownText() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toGatewayChatMessages(messages: readonly ThreadMessage[]) {
|
function toGatewayChatMessages(messages: readonly ThreadMessage[], uploads: PlaygroundUpload[] = []) {
|
||||||
return messages
|
const gatewayMessages = messages
|
||||||
.filter((message) => message.role === 'user' || message.role === 'assistant')
|
.filter((message) => message.role === 'user' || message.role === 'assistant')
|
||||||
.map((message) => ({
|
.map((message) => ({
|
||||||
content: threadMessageText(message),
|
content: threadMessageText(message),
|
||||||
role: message.role,
|
role: message.role,
|
||||||
}))
|
}))
|
||||||
.filter((message) => message.content.trim().length > 0);
|
.filter((message) => message.content.trim().length > 0);
|
||||||
|
let lastUserIndex = -1;
|
||||||
|
gatewayMessages.forEach((message, index) => {
|
||||||
|
if (message.role === 'user') lastUserIndex = index;
|
||||||
|
});
|
||||||
|
if (lastUserIndex >= 0 && uploads.length) {
|
||||||
|
gatewayMessages[lastUserIndex] = {
|
||||||
|
...gatewayMessages[lastUserIndex],
|
||||||
|
content: promptWithUploadSummary(gatewayMessages[lastUserIndex].content, uploads),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return gatewayMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadMessageText(message: ThreadMessage) {
|
function threadMessageText(message: ThreadMessage) {
|
||||||
@ -914,6 +1107,10 @@ function Composer(props: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
selectedApiKeyId?: string;
|
selectedApiKeyId?: string;
|
||||||
selectedModel?: string;
|
selectedModel?: string;
|
||||||
|
uploadAccept?: string;
|
||||||
|
uploadMessage?: string;
|
||||||
|
uploads?: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
videoMode?: VideoCreateMode;
|
videoMode?: VideoCreateMode;
|
||||||
onApiKeyChange?: (apiKeyId: string) => void;
|
onApiKeyChange?: (apiKeyId: string) => void;
|
||||||
onCreateApiKey?: () => void;
|
onCreateApiKey?: () => void;
|
||||||
@ -922,7 +1119,9 @@ function Composer(props: {
|
|||||||
onModeChange: (mode: PlaygroundMode) => void;
|
onModeChange: (mode: PlaygroundMode) => void;
|
||||||
onModelChange: (value: string) => void;
|
onModelChange: (value: string) => void;
|
||||||
onPromptChange: (value: string) => void;
|
onPromptChange: (value: string) => void;
|
||||||
|
onRemoveUpload?: (id: string) => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
|
onUploadFiles?: (files: File[]) => void;
|
||||||
onVideoModeChange?: (value: VideoCreateMode) => void;
|
onVideoModeChange?: (value: VideoCreateMode) => void;
|
||||||
}) {
|
}) {
|
||||||
const quickItems = quickPrompts[props.mode];
|
const quickItems = quickPrompts[props.mode];
|
||||||
@ -930,21 +1129,26 @@ function Composer(props: {
|
|||||||
return (
|
return (
|
||||||
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
|
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
|
||||||
<div className="composerBody">
|
<div className="composerBody">
|
||||||
<button
|
<ComposerUploadButton
|
||||||
type="button"
|
accept={props.uploadAccept ?? mediaUploadAccept}
|
||||||
className="composerUpload"
|
active={Boolean(props.uploads?.length) || props.imageHasReference === true}
|
||||||
aria-label="上传参考"
|
disabled={!props.onUploadFiles}
|
||||||
data-active={props.imageHasReference === true}
|
uploading={props.uploading}
|
||||||
onClick={() => props.mode === 'image' && props.onImageReferenceChange?.(!props.imageHasReference)}
|
onFiles={props.onUploadFiles}
|
||||||
>
|
|
||||||
<Paperclip size={18} />
|
|
||||||
</button>
|
|
||||||
<Textarea
|
|
||||||
size={props.compact ? 'sm' : 'md'}
|
|
||||||
value={props.prompt}
|
|
||||||
placeholder={placeholderByMode[props.mode]}
|
|
||||||
onChange={(event) => props.onPromptChange(event.target.value)}
|
|
||||||
/>
|
/>
|
||||||
|
<div className="composerInputStack">
|
||||||
|
<Textarea
|
||||||
|
size={props.compact ? 'sm' : 'md'}
|
||||||
|
value={props.prompt}
|
||||||
|
placeholder={placeholderByMode[props.mode]}
|
||||||
|
onChange={(event) => props.onPromptChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<UploadAttachmentList
|
||||||
|
message={props.uploadMessage}
|
||||||
|
uploads={props.uploads ?? []}
|
||||||
|
onRemove={props.onRemoveUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="composerFooter">
|
<div className="composerFooter">
|
||||||
<Select value={props.mode} onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
|
<Select value={props.mode} onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
|
||||||
@ -992,6 +1196,81 @@ function Composer(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
|
||||||
function ApiKeySelect(props: {
|
function ApiKeySelect(props: {
|
||||||
apiKeySecretsById: Record<string, string>;
|
apiKeySecretsById: Record<string, string>;
|
||||||
apiKeys: GatewayApiKey[];
|
apiKeys: GatewayApiKey[];
|
||||||
@ -1019,6 +1298,154 @@ function ApiKeySelect(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadPlaygroundFiles(
|
||||||
|
token: string,
|
||||||
|
files: File[],
|
||||||
|
options: { allowFiles: boolean; source: string },
|
||||||
|
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
|
||||||
|
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
files.forEach((file) => {
|
||||||
|
const kind = acceptedUploadKind(file, options.allowFiles);
|
||||||
|
if (!kind) {
|
||||||
|
warnings.push(options.allowFiles
|
||||||
|
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
|
||||||
|
: `已跳过 ${file.name},当前场景仅支持图片、视频和音频。`);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptWithUploadSummary(prompt: string, uploads: PlaygroundUpload[]) {
|
||||||
|
if (!uploads.length) return prompt;
|
||||||
|
const lines = uploads.map((item) => `- ${uploadKindLabel(item.kind)} ${item.name}: ${item.url}`);
|
||||||
|
return `${prompt}\n\n参考附件:\n${lines.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||||
|
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
|
||||||
|
const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
|
||||||
|
const audios = uploads.filter((item) => item.kind === 'audio').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;
|
||||||
|
}
|
||||||
|
if (images.length) {
|
||||||
|
payload.image = singleOrMany(images);
|
||||||
|
payload.image_url = images[0];
|
||||||
|
payload.images = images;
|
||||||
|
payload.reference_image = singleOrMany(images);
|
||||||
|
}
|
||||||
|
if (videos.length) {
|
||||||
|
payload.reference_video = singleOrMany(videos);
|
||||||
|
payload.video_url = videos[0];
|
||||||
|
}
|
||||||
|
if (audios.length) {
|
||||||
|
payload.reference_audio = singleOrMany(audios);
|
||||||
|
payload.audio_url = audios[0];
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleOrMany(values: string[]) {
|
||||||
|
return values.length === 1 ? values[0] : values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindLabel(kind: PlaygroundUploadKind) {
|
||||||
|
if (kind === 'image') return '图片';
|
||||||
|
if (kind === 'video') return '视频';
|
||||||
|
if (kind === 'audio') return '音频';
|
||||||
|
return '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
||||||
if (mode === 'chat') {
|
if (mode === 'chat') {
|
||||||
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
|
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
|
||||||
|
|||||||
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: '返图结果原样保存,不触发文件存储上传' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -63,6 +63,88 @@
|
|||||||
transform: rotate(-8deg);
|
transform: rotate(-8deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composerUpload:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadSpinner {
|
||||||
|
animation: composer-upload-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerInputStack {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadArea {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadList {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadChip {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: min(100%, 320px);
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadChip span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadChip small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadChip button {
|
||||||
|
display: grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-soft);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadChip button:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerUploadMessage {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes composer-upload-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.composerBody .shTextarea {
|
.composerBody .shTextarea {
|
||||||
min-height: 88px;
|
min-height: 88px;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -844,6 +844,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