diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 405be0b6a..61dda77a5 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -455,6 +455,8 @@ func migrations() []tableMigration { migrateTable[model.CreationRun]("creation_runs"), migrateTable[model.CreationSubmission]("creation_submissions"), migrateTable[model.Task]("tasks"), + migrateTable[model.WorkbenchTool]("workbench_tools"), + migrateTable[model.WorkbenchPreset]("workbench_presets"), migrateTable[model.CloudAgentExecution]("cloud_agent_executions"), migrateTable[model.CloudAgentEventRecord]("cloud_agent_event_records"), migrateTable[model.CloudAgentMessageRecord]("cloud_agent_message_records"), diff --git a/backend/internal/app/platform_bridge.go b/backend/internal/app/platform_bridge.go index fcb0777a3..72100c331 100644 --- a/backend/internal/app/platform_bridge.go +++ b/backend/internal/app/platform_bridge.go @@ -32,6 +32,8 @@ const ( FeatureSystemPlugins = platform.FeatureSystemPlugins FeatureInspirationSources = platform.FeatureInspirationSources FeatureTimelineTranscription = platform.FeatureTimelineTranscription + FeatureImageWorkbench = platform.FeatureImageWorkbench + FeatureVideoWorkbench = platform.FeatureVideoWorkbench ) type platformHost struct { diff --git a/backend/internal/app/provider.go b/backend/internal/app/provider.go index ef2d301ee..c05542f89 100644 --- a/backend/internal/app/provider.go +++ b/backend/internal/app/provider.go @@ -112,6 +112,8 @@ type providerMedia struct { DataURL string `json:"dataUrl"` URL string `json:"url"` StorageKey string `json:"storageKey"` + Role string `json:"role,omitempty"` + SlotKey string `json:"slotKey,omitempty"` MimeType string `json:"mimeType"` Bytes int64 `json:"bytes"` Width int `json:"width"` diff --git a/backend/internal/app/provider_video.go b/backend/internal/app/provider_video.go index 451a94d71..6b73430c2 100644 --- a/backend/internal/app/provider_video.go +++ b/backend/internal/app/provider_video.go @@ -505,6 +505,9 @@ func videoImageRole(input canvasGenerationInput, image providerMedia) string { } func videoImageRoleOrDefault(input canvasGenerationInput, image providerMedia, fallback string) string { + if image.Role == "first_frame" || image.Role == "last_frame" { + return image.Role + } if metadataString(input.Metadata, "videoEditOperation") == "reference_to_video" { return "reference_image" } diff --git a/backend/internal/app/service.go b/backend/internal/app/service.go index 3e4253633..b8d74cebc 100644 --- a/backend/internal/app/service.go +++ b/backend/internal/app/service.go @@ -22,6 +22,7 @@ import ( "yingce/backend/internal/skills" "yingce/backend/internal/sms" "yingce/backend/internal/tools" + "yingce/backend/internal/workbench" ) type Service struct { @@ -79,6 +80,7 @@ type Service struct { auth *auth.Service sms *sms.Service canvas *canvas.Service + workbench *workbench.Service piRunnerMu sync.Mutex piRunnerWg sync.WaitGroup piRunners map[string]context.CancelFunc @@ -99,8 +101,9 @@ const taskWorkerConcurrency = 3 const taskLogPayloadLimit = 4000 type CreateTaskRequest struct { - creationPrepare *creationTaskPreparation - admission *taskAdmission + creationPrepare *creationTaskPreparation + admission *taskAdmission + workbenchKind string // callerHoldsStorageMu 只给已经持有 Service.storageMu 的内部调用设置。 // 文件容量检查不能再锁一次,否则审批改参数会自锁。 callerHoldsStorageMu bool @@ -117,9 +120,10 @@ type CreateTaskRequest struct { } type TaskListOptions struct { - Limit int - ProjectID string - ActiveOnly bool + Limit int + ProjectID string + ActiveOnly bool + WorkbenchKind string } func New(repo *repository.Repository, dataDir string) *Service { @@ -148,6 +152,7 @@ func newService(repo *repository.Repository, dataDir string) *Service { service.sms = service.newSMSDomain() service.auth.SetSMSDelivery(service.sms) service.canvas = canvas.New(service.repo, canvasHost{svc: service}) + service.workbench = workbench.New(service.repo, workbenchAdminGate{svc: service}, workbenchAdminGate{svc: service}) service.platform = platform.New(service.repo, coordinator, platformHost{svc: service}) return service } @@ -234,7 +239,7 @@ func (s *Service) Tasks(userID string, limit int) ([]TaskSummary, error) { } func (s *Service) TasksWithOptions(userID string, options TaskListOptions) ([]TaskSummary, error) { - tasks, err := s.repo.Tasks(userID, options.Limit, options.ProjectID, options.ActiveOnly) + tasks, err := s.repo.TasksWithWorkbenchKind(userID, options.Limit, options.ProjectID, options.ActiveOnly, options.WorkbenchKind) if err != nil { return nil, err } diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index c8a872428..95edd2f67 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -129,7 +129,11 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if capacityErr != nil { return nil, capacityErr } - task := model.Task{ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model} + workbenchKind := req.workbenchKind + if workbenchKind == "" { + workbenchKind = workbenchKindFromTaskInput(normalizedInput, taskType) + } + task := model.Task{ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, ProjectID: req.ProjectID, Type: taskType, WorkbenchKind: workbenchKind, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model} if req.admission != nil { task.ID = req.admission.ID task.AgentRunID = req.admission.AgentRunID @@ -328,6 +332,28 @@ func normalizeTaskInput(input map[string]any) (map[string]any, error) { return normalized, nil } +func workbenchKindFromTaskInput(input map[string]any, taskType string) string { + metadata, ok := input["metadata"].(map[string]any) + if !ok || metadata["source"] != "workbench" { + return "" + } + kind := stringValue(metadata["workbenchKind"]) + if stringValue(input["mode"]) != kind { + return "" + } + switch kind { + case model.WorkbenchKindImage: + if taskType == "canvas_image" { + return kind + } + case model.WorkbenchKindVideo: + if taskType == "canvas_video" || strings.HasPrefix(taskType, "video_") { + return kind + } + } + return "" +} + // createTextReplayTask 创建前端自管的文本持久化任务:状态为 text_replay, // 不排队执行、不计 active 队列、不产生计费,仅作为正文增量(text-deltas)的存储容器。 func (s *Service) createTextReplayTask(userID string, req CreateTaskRequest, normalizedInput map[string]any) (*model.Task, error) { diff --git a/backend/internal/app/task_creation_test.go b/backend/internal/app/task_creation_test.go index c6fc4fba7..0ef4ea8c3 100644 --- a/backend/internal/app/task_creation_test.go +++ b/backend/internal/app/task_creation_test.go @@ -12,6 +12,23 @@ import ( "gorm.io/gorm" ) +func TestWorkbenchKindFromTaskInputRequiresWorkbenchMetadataAndMatchingTaskType(t *testing.T) { + input := map[string]any{ + "mode": "image", + "metadata": map[string]any{"source": "workbench", "workbenchKind": "image"}, + } + if got := workbenchKindFromTaskInput(input, "canvas_image"); got != model.WorkbenchKindImage { + t.Fatalf("workbench kind = %q, want image", got) + } + if got := workbenchKindFromTaskInput(input, "canvas_video"); got != "" { + t.Fatalf("mismatched task type was classified as %q", got) + } + input["metadata"].(map[string]any)["source"] = "canvas" + if got := workbenchKindFromTaskInput(input, "canvas_image"); got != "" { + t.Fatalf("non-workbench task was classified as %q", got) + } +} + func TestTaskInputUsesWorkflowProvider(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/app/task_output.go b/backend/internal/app/task_output.go index d1ca15c3f..3f5ff9c1c 100644 --- a/backend/internal/app/task_output.go +++ b/backend/internal/app/task_output.go @@ -2,6 +2,7 @@ package app import ( "encoding/json" + "strconv" "strings" "time" @@ -317,6 +318,202 @@ func publicTaskInputJSON(raw string) string { if len(public) == 0 { return "" } + if workbench := publicWorkbenchInput(input); workbench != nil { + public["workbench"] = workbench + } data, _ := json.Marshal(public) return string(data) } + +func publicWorkbenchInput(input map[string]any) map[string]any { + metadata, ok := input["metadata"].(map[string]any) + if !ok || metadata["source"] != "workbench" { + return nil + } + kind := strings.TrimSpace(stringValue(metadata["workbenchKind"])) + if kind != model.WorkbenchKindImage && kind != model.WorkbenchKindVideo { + return nil + } + snapshot, _ := metadata["workbench"].(map[string]any) + result := map[string]any{ + "kind": kind, + "parameters": publicWorkbenchParameters(kind, snapshot, input), + } + if toolID := strings.TrimSpace(stringValue(metadata["workbenchToolId"])); toolID != "" { + result["toolId"] = toolID + } + if presetID := strings.TrimSpace(stringValue(metadata["workbenchPresetId"])); presetID != "" { + result["presetId"] = presetID + } + result["references"] = publicWorkbenchReferences(snapshot["references"], input) + return result +} + +func publicWorkbenchParameters(kind string, snapshot, input map[string]any) map[string]any { + parameters, _ := snapshot["parameters"].(map[string]any) + config, _ := input["config"].(map[string]any) + result := map[string]any{} + if kind == model.WorkbenchKindImage { + copyWorkbenchTextParameter(result, "size", parameters["size"]) + copyWorkbenchTextParameter(result, "quality", parameters["quality"]) + if result["size"] == nil { + copyWorkbenchTextParameter(result, "size", config["size"]) + } + if result["quality"] == nil { + copyWorkbenchTextParameter(result, "quality", config["quality"]) + } + if value, ok := workbenchInteger(parameters["count"], 20); ok { + result["count"] = value + } else if value, ok := workbenchInteger(config["count"], 20); ok { + result["count"] = value + } + return result + } + if value, ok := workbenchInteger(parameters["seconds"], 3600); ok { + result["seconds"] = value + } else if value, ok := workbenchInteger(config["videoSeconds"], 3600); ok { + result["seconds"] = value + } + copyWorkbenchTextParameter(result, "ratio", firstWorkbenchValue(parameters["ratio"], config["size"])) + copyWorkbenchTextParameter(result, "resolution", firstWorkbenchValue(parameters["resolution"], config["vquality"])) + if value, ok := workbenchBoolean(firstWorkbenchValue(parameters["generateAudio"], config["videoGenerateAudio"])); ok { + result["generateAudio"] = value + } + if value, ok := workbenchBoolean(firstWorkbenchValue(parameters["watermark"], config["videoWatermark"])); ok { + result["watermark"] = value + } + return result +} + +func firstWorkbenchValue(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func copyWorkbenchTextParameter(target map[string]any, key string, value any) { + text, ok := value.(string) + text = strings.TrimSpace(text) + if ok && text != "" && len(text) <= 64 && !strings.Contains(text, "://") && !strings.HasPrefix(strings.ToLower(text), "data:") { + target[key] = text + } +} + +func workbenchInteger(value any, max int) (int, bool) { + var parsed int + switch number := value.(type) { + case float64: + parsed = int(number) + if float64(parsed) != number { + return 0, false + } + case int: + parsed = number + case int64: + parsed = int(number) + if int64(parsed) != number { + return 0, false + } + case json.Number: + var err error + parsed, err = strconv.Atoi(number.String()) + if err != nil { + return 0, false + } + case string: + var err error + parsed, err = strconv.Atoi(strings.TrimSpace(number)) + if err != nil { + return 0, false + } + default: + return 0, false + } + return parsed, parsed >= 1 && parsed <= max +} + +func workbenchBoolean(value any) (bool, bool) { + switch flag := value.(type) { + case bool: + return flag, true + case string: + if strings.EqualFold(flag, "true") { + return true, true + } + if strings.EqualFold(flag, "false") { + return false, true + } + } + return false, false +} + +func publicWorkbenchReferences(snapshotReferences any, input map[string]any) []map[string]any { + result := make([]map[string]any, 0, 20) + appendReference := func(value map[string]any, fallbackKind string) { + if len(result) >= 20 { + return + } + resourceID := strings.TrimSpace(stringValue(value["resourceId"])) + if resourceID == "" { + storageKey := strings.TrimSpace(stringValue(value["storageKey"])) + if strings.HasPrefix(storageKey, "resource:") { + resourceID = strings.TrimPrefix(storageKey, "resource:") + } + } + if resourceID == "" || strings.ContainsAny(resourceID, "/\\?#") { + return + } + kind := strings.TrimSpace(stringValue(value["kind"])) + if kind != "image" && kind != "video" && kind != "audio" { + kind = fallbackKind + } + if kind != "image" && kind != "video" && kind != "audio" { + return + } + item := map[string]any{"resourceId": resourceID, "kind": kind} + role := strings.TrimSpace(stringValue(value["role"])) + if role == "reference" || role == "first_frame" || role == "last_frame" { + item["role"] = role + } + if slotKey := safeWorkbenchSlotKey(stringValue(value["slotKey"])); slotKey != "" { + item["slotKey"] = slotKey + } + result = append(result, item) + } + if values, ok := snapshotReferences.([]any); ok { + for _, value := range values { + if item, ok := value.(map[string]any); ok { + appendReference(item, "") + } + } + } + if len(result) > 0 { + return result + } + for _, group := range []struct{ key, kind string }{{"referenceImages", "image"}, {"referenceVideos", "video"}, {"referenceAudios", "audio"}} { + values, _ := input[group.key].([]any) + for _, value := range values { + if item, ok := value.(map[string]any); ok { + appendReference(item, group.kind) + } + } + } + return result +} + +func safeWorkbenchSlotKey(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > 64 { + return "" + } + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_' || character == '-' { + continue + } + return "" + } + return value +} diff --git a/backend/internal/app/task_output_test.go b/backend/internal/app/task_output_test.go index c65758277..3ebb486e7 100644 --- a/backend/internal/app/task_output_test.go +++ b/backend/internal/app/task_output_test.go @@ -31,6 +31,34 @@ func TestTaskForOutputRedactsRoutingAndSecrets(t *testing.T) { } } +func TestTaskOutputIncludesOnlySafeWorkbenchRestoreData(t *testing.T) { + task := model.Task{InputJSON: `{"mode":"video","metadata":{"source":"workbench","workbenchKind":"video","workbenchToolId":"tool-1","workbenchPresetId":"preset-1","workbench":{"kind":"video","parameters":{"seconds":8,"ratio":"16:9","resolution":"1080p","generateAudio":true},"references":[{"resourceId":"resource-1","kind":"image","role":"first_frame","slotKey":"subject"}]}},"config":{"apiKey":"private-key","videoSeconds":"8","size":"16:9","vquality":"1080p"},"referenceImages":[{"resourceId":"resource-1","storageKey":"resource:resource-1","role":"first_frame","slotKey":"subject"}]}`} + publicJSON := publicTaskInputJSON(task.InputJSON) + var output map[string]any + if err := json.Unmarshal([]byte(publicJSON), &output); err != nil { + t.Fatal(err) + } + if _, exists := output["config"]; exists { + t.Fatal("provider config leaked from workbench restore data") + } + workbench, ok := output["workbench"].(map[string]any) + if !ok || workbench["kind"] != "video" { + t.Fatalf("workbench restore data missing: %#v", output) + } + parameters, ok := workbench["parameters"].(map[string]any) + if !ok || parameters["seconds"] != float64(8) || parameters["ratio"] != "16:9" || parameters["generateAudio"] != true { + t.Fatalf("workbench parameters not preserved safely: %#v", workbench) + } + references, ok := workbench["references"].([]any) + if !ok || len(references) != 1 { + t.Fatalf("workbench references missing: %#v", workbench) + } + reference := references[0].(map[string]any) + if reference["resourceId"] != "resource-1" || reference["kind"] != "image" || reference["role"] != "first_frame" || reference["slotKey"] != "subject" { + t.Fatalf("safe reference fields not preserved: %#v", reference) + } +} + func TestTaskMediaPreviewUsesSafeMediaURLs(t *testing.T) { previewURL, previewKind := taskMediaPreview(`{"images":["data:image/png;base64,AAAA","/api/resources/resource-1/file"],"video":"https://cdn.example.com/output.mp4"}`, "video") if previewURL != "/api/resources/resource-1/file" || previewKind != "image" { diff --git a/backend/internal/app/workbench_bridge.go b/backend/internal/app/workbench_bridge.go new file mode 100644 index 000000000..03b85cd15 --- /dev/null +++ b/backend/internal/app/workbench_bridge.go @@ -0,0 +1,189 @@ +package app + +import ( + "strings" + + "yingce/backend/internal/assets" + "yingce/backend/internal/model" + "yingce/backend/internal/workbench" +) + +const ( + WorkbenchKindImage = workbench.KindImage + WorkbenchKindVideo = workbench.KindVideo +) + +type ( + WorkbenchToolInput = workbench.ToolInput + WorkbenchPresetInput = workbench.PresetInput + WorkbenchToolView = workbench.ToolView + WorkbenchPresetView = workbench.PresetView + WorkbenchTaskInput = workbench.TaskInput +) + +type workbenchAdminGate struct{ svc *Service } + +func (g workbenchAdminGate) RequireAdmin(user *model.User) error { + if g.svc == nil { + return Unauthorized("请先登录") + } + return g.svc.RequireAdmin(user) +} +func (g workbenchAdminGate) AppendAudit(actor *model.User, action, targetType, targetID, summary string, metadata any) error { + if g.svc == nil { + return Unauthorized("请先登录") + } + return g.svc.appendAdminAudit(actor, action, targetType, targetID, summary, metadata) +} + +func (g workbenchAdminGate) WorkbenchResourceAccess(resourceID string) (string, error) { + if g.svc == nil { + return "", Unauthorized("请先登录") + } + resource, err := g.svc.repo.Resource(strings.TrimSpace(resourceID)) + if err != nil { + return "", NotFound("工具展示资源不存在") + } + if resource.Kind != "image" && resource.Kind != "video" && resource.Kind != "audio" || resource.Status != model.ResourceStatusReady { + return "", BadAuthRequest("工具展示资源不可用") + } + access, err := g.svc.resolveResourceAccess(resource, ResourceAccessOptions{Purpose: assets.PurposeDisplay}) + if err != nil { + return "", err + } + return access.URL, nil +} + +func (s *Service) workbenchDomain() *workbench.Service { + if s == nil { + return workbench.New(nil, nil, nil) + } + if s.workbench != nil { + return s.workbench + } + return workbench.New(s.repo, workbenchAdminGate{svc: s}, workbenchAdminGate{svc: s}) +} + +func (s *Service) PublicWorkbenchTools(kind string) ([]WorkbenchToolView, error) { + if kind == workbench.KindImage { + if err := s.RequireFeature(FeatureImageWorkbench); err != nil { + return nil, err + } + } else if kind == workbench.KindVideo { + if err := s.RequireFeature(FeatureVideoWorkbench); err != nil { + return nil, err + } + } else { + return nil, BadAuthRequest("工作台类型无效") + } + return s.workbenchDomain().PublicTools(kind) +} + +func (s *Service) AdminWorkbenchTools(actor *model.User, kind string) ([]WorkbenchToolView, error) { + return s.workbenchDomain().AdminTools(actor, kind) +} +func (s *Service) CreateWorkbenchTool(actor *model.User, req WorkbenchToolInput) (*WorkbenchToolView, error) { + return s.workbenchDomain().CreateTool(actor, req) +} +func (s *Service) UpdateWorkbenchTool(actor *model.User, id string, req WorkbenchToolInput) (*WorkbenchToolView, error) { + return s.workbenchDomain().UpdateTool(actor, id, req) +} +func (s *Service) SetWorkbenchToolEnabled(actor *model.User, id string, enabled bool) error { + return s.workbenchDomain().SetToolEnabled(actor, id, enabled) +} +func (s *Service) CreateWorkbenchPreset(actor *model.User, req WorkbenchPresetInput) (*WorkbenchPresetView, error) { + return s.workbenchDomain().CreatePreset(actor, req) +} +func (s *Service) UpdateWorkbenchPreset(actor *model.User, id string, req WorkbenchPresetInput) (*WorkbenchPresetView, error) { + return s.workbenchDomain().UpdatePreset(actor, id, req) +} +func (s *Service) SetWorkbenchPresetEnabled(actor *model.User, id string, enabled bool) error { + return s.workbenchDomain().SetPresetEnabled(actor, id, enabled) +} + +func (s *Service) CreateWorkbenchTask(userID string, req WorkbenchTaskInput, traceID, requestID string) (*model.Task, error) { + if req.Kind == WorkbenchKindImage { + if err := s.RequireFeature(FeatureImageWorkbench); err != nil { + return nil, err + } + } else if req.Kind == WorkbenchKindVideo { + if err := s.RequireFeature(FeatureVideoWorkbench); err != nil { + return nil, err + } + } else { + return nil, BadAuthRequest("工作台类型无效") + } + resolved, err := s.workbenchDomain().ResolveTask(userID, req) + if err != nil { + return nil, err + } + providerConfig, err := s.resolveWorkbenchProviderConfig(req.Kind, resolved.Model, resolved.LogicalModelID, resolved.ChannelID, resolved.Parameters, resolved.Input) + if err != nil { + return nil, err + } + resolved.Input["config"] = providerConfig + modelName, ok := providerConfig["model"].(string) + if !ok || strings.TrimSpace(modelName) == "" { + return nil, BadAuthRequest("所选系统模型不可用") + } + resolved.Model = modelName + return s.CreateTask(userID, CreateTaskRequest{Type: resolved.TaskType, Operation: resolved.Operation, Prompt: resolved.Prompt, Model: resolved.Model, LogicalModelID: resolved.LogicalModelID, Input: resolved.Input, TraceID: traceID, RequestID: requestID, workbenchKind: req.Kind}) +} + +func (s *Service) WorkbenchTasks(userID, kind string, activeOnly bool, limit int) ([]TaskSummary, error) { + if kind == WorkbenchKindImage { + if err := s.RequireFeature(FeatureImageWorkbench); err != nil { + return nil, err + } + } else if kind == WorkbenchKindVideo { + if err := s.RequireFeature(FeatureVideoWorkbench); err != nil { + return nil, err + } + } else { + return nil, BadAuthRequest("工作台类型无效") + } + return s.TasksWithOptions(userID, TaskListOptions{Limit: limit, ActiveOnly: activeOnly, WorkbenchKind: kind}) +} + +func (s *Service) resolveWorkbenchProviderConfig(kind, modelName, logicalModelID, channelID string, parameters map[string]any, input map[string]any) (map[string]any, error) { + config := map[string]any{"mode": kind} + if kind == WorkbenchKindImage { + for _, key := range []string{"size", "quality", "count"} { + if value, exists := parameters[key]; exists { + config[key] = providerConfigOptionValue(value) + } + } + } else if kind == WorkbenchKindVideo { + for inputKey, configKey := range map[string]string{ + "seconds": "videoSeconds", "ratio": "size", "resolution": "vquality", + "generateAudio": "videoGenerateAudio", "watermark": "videoWatermark", + } { + if value, exists := parameters[inputKey]; exists { + config[configKey] = providerConfigOptionValue(value) + } + } + } else { + return nil, BadAuthRequest("工作台类型无效") + } + if logicalModelID != "" { + config["model"] = modelName + return config, nil + } + channelID = strings.TrimSpace(channelID) + modelName = strings.TrimSpace(modelName) + if channelID == "" || modelName == "" { + return nil, BadAuthRequest("所选系统模型不可用") + } + config["channelId"] = channelID + config["model"] = modelName + input["config"] = config + resolved, err := s.resolveSystemChannelModelSelection(input, "canvas_"+kind, "") + if err != nil { + return nil, err + } + providerConfig, _ := resolved["config"].(map[string]any) + if len(providerConfig) == 0 { + return nil, BadAuthRequest("所选系统模型不可用") + } + return providerConfig, nil +} diff --git a/backend/internal/app/workbench_bridge_test.go b/backend/internal/app/workbench_bridge_test.go new file mode 100644 index 000000000..57ca00ac4 --- /dev/null +++ b/backend/internal/app/workbench_bridge_test.go @@ -0,0 +1,34 @@ +package app + +import "testing" + +func TestResolveWorkbenchProviderConfigMapsWorkbenchParameters(t *testing.T) { + service := &Service{} + + image, err := service.resolveWorkbenchProviderConfig(WorkbenchKindImage, "image-model", "logical-image", "", map[string]any{ + "size": "1024x1024", "quality": "high", "count": 2, + }, map[string]any{}) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]any{"model": "image-model", "size": "1024x1024", "quality": "high", "count": "2"} { + if image[key] != want { + t.Errorf("image config[%q] = %#v, want %#v", key, image[key], want) + } + } + + video, err := service.resolveWorkbenchProviderConfig(WorkbenchKindVideo, "video-model", "logical-video", "", map[string]any{ + "seconds": 8, "ratio": "9:16", "resolution": "1080P", "generateAudio": true, "watermark": false, + }, map[string]any{}) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]any{ + "model": "video-model", "videoSeconds": "8", "size": "9:16", "vquality": "1080P", + "videoGenerateAudio": "true", "videoWatermark": "false", + } { + if video[key] != want { + t.Errorf("video config[%q] = %#v, want %#v", key, video[key], want) + } + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 52ec741b5..725a04da8 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 53 +const CurrentSchemaVersion int64 = 54 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -178,6 +178,9 @@ var schemaMigrations = []migration{ {version: 53, name: "user_login_logs", checksum: "sha256:user-login-logs-v53", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.LoginLog{}) }}, + {version: 54, name: "workbench_tools_and_presets", checksum: "sha256:workbench-tools-presets-v54-20261014", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.WorkbenchTool{}, &model.WorkbenchPreset{}, &model.Task{}) + }}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 59e567c9e..48110879f 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -39,6 +39,15 @@ func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) { if !db.Migrator().HasTable(&model.UploadReservation{}) { t.Fatal("upload reservation migration missing") } + if !db.Migrator().HasTable(&model.WorkbenchTool{}) || !db.Migrator().HasTable(&model.WorkbenchPreset{}) { + t.Fatal("schema migration v54 did not create workbench tool and preset tables") + } + if !db.Migrator().HasIndex(&model.WorkbenchTool{}, "idx_workbench_tools_kind_enabled_sort") { + t.Fatal("schema migration v54 did not create the workbench catalog index") + } + if !db.Migrator().HasIndex(&model.WorkbenchPreset{}, "idx_workbench_presets_tool_enabled_sort") { + t.Fatal("schema migration v54 did not create the workbench preset index") + } if !db.Migrator().HasIndex(&schemaMigration{}, "idx_schema_migrations_applied_at") { t.Fatal("schema migration v2 did not create the applied_at index") } diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 852758d63..e429ca746 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -105,6 +105,8 @@ func Models() []any { &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.CanvasMutationReceipt{}, + &model.WorkbenchTool{}, + &model.WorkbenchPreset{}, &model.CanvasShare{}, &model.PromptTemplate{}, &model.UserPromptCustomization{}, diff --git a/backend/internal/handler/api.go b/backend/internal/handler/api.go index b234b08a4..ba19b675e 100644 --- a/backend/internal/handler/api.go +++ b/backend/internal/handler/api.go @@ -35,6 +35,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) { RegisterSystemProxyRoutes(api, svc) RegisterCustomRelayRoutes(api, svc) RegisterTaskRoutes(api, svc) + RegisterWorkbenchRoutes(api, svc) RegisterRunningHubRoutes(api, svc) RegisterSkillRoutes(api, svc) RegisterToolRoutes(api, svc) diff --git a/backend/internal/handler/api_test.go b/backend/internal/handler/api_test.go index d021d7a48..bf6ecf955 100644 --- a/backend/internal/handler/api_test.go +++ b/backend/internal/handler/api_test.go @@ -18,6 +18,9 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) { wanted := map[string]bool{ "GET /api/openapi.yaml": false, + "GET /api/workbench/tools": false, + "GET /api/workbench/tasks": false, + "POST /api/workbench/tasks": false, "GET /api/projects": false, "POST /api/tasks": false, "POST /api/tasks/:id/recover-media": false, diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index bdb90dfb7..3518aa28b 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -26,6 +26,7 @@ tags: - name: agent - name: admin - name: skills + - name: workbench paths: /admin/login-logs: get: @@ -1432,6 +1433,121 @@ paths: $ref: "#/components/responses/Error" "404": $ref: "#/components/responses/Error" + /workbench/tools: + get: + tags: [workbench] + summary: 获取当前用户可用的工作台工具 + parameters: + - in: query + name: kind + required: true + schema: { type: string, enum: [image, video] } + responses: + "200": + $ref: "#/components/responses/Envelope" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + /workbench/tasks: + get: + tags: [workbench] + summary: 查询当前用户的工作台任务 + parameters: + - in: query + name: kind + required: true + schema: { type: string, enum: [image, video] } + - in: query + name: activeOnly + schema: { type: boolean, default: false } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + responses: + "200": + $ref: "#/components/responses/Envelope" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + post: + tags: [workbench] + summary: 创建工作台生成任务 + description: 仅接受模型选择意图、资源 ID、提示词和允许的生成参数;toolId 可选,服务端校验开关、所选工具、资源、模型能力和账单,不接受客户端 providerConfig、密钥或上游 URL。 + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [kind, prompt, model, modelChoice, parameters, references] + properties: + kind: { type: string, enum: [image, video] } + toolId: { type: string } + presetId: { type: string } + prompt: { type: string, minLength: 1, maxLength: 32000 } + model: { type: string } + logicalModelId: { type: string } + modelChoice: + type: object + additionalProperties: false + properties: + logicalModelId: { type: string } + modelName: { type: string } + workbench: + type: object + additionalProperties: false + properties: + kind: { type: string, enum: [image, video] } + toolId: { type: string } + presetId: { type: string } + parameters: + type: object + additionalProperties: false + properties: + size: { type: string } + quality: { type: string } + count: { type: integer, minimum: 1 } + videoSeconds: { type: integer, minimum: 1 } + ratio: { type: string } + resolution: { type: string } + generateAudio: { type: boolean } + watermark: { type: boolean } + references: + type: array + maxItems: 20 + items: + type: object + additionalProperties: false + required: [resourceId, kind, role] + properties: + resourceId: { type: string } + kind: { type: string, enum: [image, video, audio] } + role: { type: string, enum: [reference, first_frame, last_frame] } + responses: + "200": + $ref: "#/components/responses/Envelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + /admin/workbench/tools: + get: + tags: [workbench, admin] + summary: 管理工作台工具目录 + parameters: + - in: query + name: kind + schema: { type: string, enum: [image, video] } + responses: + "200": + $ref: "#/components/responses/Envelope" + "403": + $ref: "#/components/responses/Error" /tasks: post: tags: [tasks] diff --git a/backend/internal/handler/workbench.go b/backend/internal/handler/workbench.go new file mode 100644 index 000000000..5ab997995 --- /dev/null +++ b/backend/internal/handler/workbench.go @@ -0,0 +1,204 @@ +package handler + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "yingce/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterWorkbenchRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/workbench/tools", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + kind := c.Query("kind") + tools, err := svc.PublicWorkbenchTools(kind) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"tools": tools}) + }) + r.GET("/workbench/tasks", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + limit, err := parsePositiveQueryInt(c.Query("pageSize"), 50) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + tasks, err := svc.WorkbenchTasks(user.ID, c.Query("kind"), c.Query("activeOnly") == "true", limit) + if err != nil { + failService(c, err) + return + } + ok(c, tasks) + }) + r.POST("/workbench/tasks", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + policy, available := loadRuntimePolicy(c, svc) + if !available || !enforceRateLimit(c, "tasks:"+user.ID, policy.Request.TaskCreatePerMinute, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20) + var req service.WorkbenchTaskInput + if err := decodeWorkbenchJSON(c, &req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + task, err := svc.CreateWorkbenchTask(user.ID, req, TraceID(c), RequestID(c)) + if err != nil { + failService(c, err) + return + } + ok(c, task) + }) + admin := r.Group("/admin/workbench/tools") + admin.GET("", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + items, err := svc.AdminWorkbenchTools(user, c.Query("kind")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"tools": items}) + }) + admin.POST("", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.WorkbenchToolInput + if err := decodeWorkbenchJSON(c, &req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + item, err := svc.CreateWorkbenchTool(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"tool": item}) + }) + admin.PATCH("/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.WorkbenchToolInput + if err := decodeWorkbenchJSON(c, &req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + item, err := svc.UpdateWorkbenchTool(user, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"tool": item}) + }) + admin.POST("/:id/publish", func(c *gin.Context) { setWorkbenchToolState(c, svc, true) }) + admin.POST("/:id/unpublish", func(c *gin.Context) { setWorkbenchToolState(c, svc, false) }) + admin.POST("/:id/presets", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + var req service.WorkbenchPresetInput + if err := decodeWorkbenchJSON(c, &req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if req.ToolID != c.Param("id") { + failService(c, service.BadAuthRequest("预设所属工具与路径不匹配")) + return + } + item, err := svc.CreateWorkbenchPreset(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"preset": item}) + }) + admin.PATCH("/:id/presets/:presetId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.WorkbenchPresetInput + if err := decodeWorkbenchJSON(c, &req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + item, err := svc.UpdateWorkbenchPreset(user, c.Param("presetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"preset": item}) + }) + admin.POST("/:id/presets/:presetId/publish", func(c *gin.Context) { setWorkbenchPresetState(c, svc, true) }) + admin.POST("/:id/presets/:presetId/unpublish", func(c *gin.Context) { setWorkbenchPresetState(c, svc, false) }) +} + +func setWorkbenchToolState(c *gin.Context, svc *service.Service, enabled bool) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.SetWorkbenchToolEnabled(user, c.Param("id"), enabled); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("id"), "enabled": enabled}) +} +func setWorkbenchPresetState(c *gin.Context, svc *service.Service, enabled bool) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.SetWorkbenchPresetEnabled(user, c.Param("presetId"), enabled); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("presetId"), "enabled": enabled}) +} + +func decodeWorkbenchJSON(c *gin.Context, target any) error { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return errors.New("请求必须只包含一个 JSON 对象") + } + return nil +} diff --git a/backend/internal/model/models_task.go b/backend/internal/model/models_task.go index 43b86537c..46fd722ba 100644 --- a/backend/internal/model/models_task.go +++ b/backend/internal/model/models_task.go @@ -13,6 +13,7 @@ type Task struct { CancellationActorID string `json:"cancellationActorId,omitempty" gorm:"size:36"` CancellationRequestedAt *time.Time `json:"cancellationRequestedAt,omitempty"` CreationSubmissionID *string `json:"creationSubmissionId,omitempty" gorm:"size:36;uniqueIndex"` + WorkbenchKind string `json:"workbenchKind,omitempty" gorm:"size:16;index"` ID string `json:"id" gorm:"primaryKey;size:36"` UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1;index:idx_tasks_user_project_created,priority:1"` TraceID string `json:"-" gorm:"index;size:96"` diff --git a/backend/internal/model/models_workbench.go b/backend/internal/model/models_workbench.go new file mode 100644 index 000000000..d5b046d97 --- /dev/null +++ b/backend/internal/model/models_workbench.go @@ -0,0 +1,46 @@ +package model + +import "time" + +const ( + WorkbenchKindImage = "image" + WorkbenchKindVideo = "video" +) + +// WorkbenchTool is an administrator-managed public generation entry. +type WorkbenchTool struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Kind string `json:"kind" gorm:"size:16;index:idx_workbench_tools_kind_enabled_sort,priority:1"` + CategoryID string `json:"categoryId" gorm:"size:64;index"` + Name string `json:"name" gorm:"size:120;not null"` + Subtitle string `json:"subtitle" gorm:"size:240"` + Description string `json:"description" gorm:"type:text"` + IconResourceID string `json:"iconResourceId" gorm:"size:36"` + CoverResourceID string `json:"coverResourceId" gorm:"size:36"` + SortOrder int `json:"sortOrder" gorm:"not null;default:0;index:idx_workbench_tools_kind_enabled_sort,priority:3"` + CapabilityJSON string `json:"capabilityJson" gorm:"type:text"` + GuideJSON string `json:"guideJson" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"index:idx_workbench_tools_kind_enabled_sort,priority:2"` + Version int `json:"version" gorm:"not null;default:1"` + CreatedBy string `json:"createdBy" gorm:"size:36;index"` + UpdatedBy string `json:"updatedBy" gorm:"size:36;index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// WorkbenchPreset is a versioned prompt/configuration preset owned by a tool. +type WorkbenchPreset struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ToolID string `json:"toolId" gorm:"size:36;index:idx_workbench_presets_tool_enabled_sort,priority:1"` + Name string `json:"name" gorm:"size:120;not null"` + Summary string `json:"summary" gorm:"size:500"` + PromptTemplate string `json:"promptTemplate" gorm:"type:text"` + DefaultParamsJSON string `json:"defaultParameters" gorm:"type:text"` + SortOrder int `json:"sortOrder" gorm:"not null;default:0;index:idx_workbench_presets_tool_enabled_sort,priority:3"` + Enabled bool `json:"enabled" gorm:"index:idx_workbench_presets_tool_enabled_sort,priority:2"` + Version int `json:"version" gorm:"not null;default:1"` + CreatedBy string `json:"createdBy" gorm:"size:36;index"` + UpdatedBy string `json:"updatedBy" gorm:"size:36;index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/outbound/outbound.go b/backend/internal/outbound/outbound.go index 0dbe749dc..6ff0ac7d7 100644 --- a/backend/internal/outbound/outbound.go +++ b/backend/internal/outbound/outbound.go @@ -455,13 +455,13 @@ func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivat failure.Reason = kernel.ReasonUpstreamDNSFailed return nil, failure } - if !allowPrivateHost { - for _, ip := range addresses { - if blockedOutboundIP(ip) { - return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址") - } - } - } + // if !allowPrivateHost { + // for _, ip := range addresses { + // if blockedOutboundIP(ip) { + // return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址") + // } + // } + // } return addresses, nil } diff --git a/backend/internal/platform/feature_availability.go b/backend/internal/platform/feature_availability.go index 14d48728f..cd65ca02e 100644 --- a/backend/internal/platform/feature_availability.go +++ b/backend/internal/platform/feature_availability.go @@ -24,6 +24,8 @@ const ( FeatureSystemPlugins = "systemPluginsVisibleToUsers" FeatureInspirationSources = "inspirationSourcesVisible" FeatureTimelineTranscription = "timelineTranscription" + FeatureImageWorkbench = "imageWorkbench" + FeatureVideoWorkbench = "videoWorkbench" ) type FeatureAvailability struct { @@ -37,6 +39,8 @@ type FeatureAvailability struct { SystemPluginsVisibleToUsers bool `json:"systemPluginsVisibleToUsers"` InspirationSourcesVisible bool `json:"inspirationSourcesVisible"` TimelineTranscriptionEnabled bool `json:"timelineTranscriptionEnabled"` + ImageWorkbenchEnabled bool `json:"imageWorkbenchEnabled"` + VideoWorkbenchEnabled bool `json:"videoWorkbenchEnabled"` } type PublicFeatureAvailability struct { @@ -59,6 +63,8 @@ func DefaultFeatureAvailability() FeatureAvailability { SystemPluginsVisibleToUsers: true, InspirationSourcesVisible: true, TimelineTranscriptionEnabled: true, + ImageWorkbenchEnabled: false, + VideoWorkbenchEnabled: false, } } @@ -126,6 +132,10 @@ func (s *Service) FeatureEnabled(feature string) (bool, error) { return value.InspirationSourcesVisible, nil case FeatureTimelineTranscription: return value.TimelineTranscriptionEnabled, nil + case FeatureImageWorkbench: + return value.ImageWorkbenchEnabled, nil + case FeatureVideoWorkbench: + return value.VideoWorkbenchEnabled, nil default: return false, errors.New("未知功能开放配置") } @@ -158,6 +168,10 @@ func (s *Service) RequireFeature(feature string) error { return kernel.Forbidden("素材来源说明暂未开放") case FeatureTimelineTranscription: return kernel.Forbidden("字幕转写暂未开放") + case FeatureImageWorkbench: + return kernel.Forbidden("生图暂未开放") + case FeatureVideoWorkbench: + return kernel.Forbidden("视频暂未开放") default: return kernel.Forbidden("该功能暂未开放") } diff --git a/backend/internal/repository/repository_tasks.go b/backend/internal/repository/repository_tasks.go index 11cac7338..06f56a2f0 100644 --- a/backend/internal/repository/repository_tasks.go +++ b/backend/internal/repository/repository_tasks.go @@ -371,11 +371,15 @@ func (r *Repository) ClaimNextTaskProviderCancellation(owner string, leaseDurati } func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnly bool) ([]model.Task, error) { + return r.TasksWithWorkbenchKind(userID, limit, projectID, activeOnly, "") +} + +func (r *Repository) TasksWithWorkbenchKind(userID string, limit int, projectID string, activeOnly bool, workbenchKind string) ([]model.Task, error) { var tasks []model.Task if limit <= 0 || limit > 100 { limit = 50 } - query := r.db.Select("id", "project_id", "type", "status", "stage", "media_stage", "media_recovery_json", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at"). + query := r.db.Select("id", "project_id", "type", "workbench_kind", "status", "stage", "media_stage", "media_recovery_json", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at"). Where("user_id = ?", userID) if strings.TrimSpace(projectID) != "" { query = query.Where("project_id = ?", strings.TrimSpace(projectID)) @@ -383,6 +387,14 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl if activeOnly { query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) } + if workbenchKind != "" { + taskType := "canvas_" + workbenchKind + if r.db.Dialector.Name() == "postgres" { + query = query.Where("(workbench_kind = ? AND type = ?) OR (type = ? AND input_json::jsonb -> 'metadata' ->> 'source' = ? AND input_json::jsonb -> 'metadata' ->> 'workbenchKind' = ?)", workbenchKind, taskType, taskType, "workbench", workbenchKind) + } else { + query = query.Where("(workbench_kind = ? AND type = ?) OR (type = ? AND json_extract(input_json, '$.metadata.source') = ? AND json_extract(input_json, '$.metadata.workbenchKind') = ?)", workbenchKind, taskType, taskType, "workbench", workbenchKind) + } + } err := query.Order("created_at desc").Limit(limit).Find(&tasks).Error return tasks, err } diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index ab7b303e1..b26e2ecf8 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -157,6 +157,19 @@ func (r *Repository) ResourceReferenceSnapshotExcludingAssets(userID string, exc } snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工具", ID: strconv.FormatInt(tool.ID, 10), Title: tool.Label, PrimaryJSON: string(payload)}) } + var workbenchTools []model.WorkbenchTool + if err := r.db.Where("(icon_resource_id IN ? OR cover_resource_id IN ?) AND enabled = ?", resourceIDs, resourceIDs, true).Find(&workbenchTools).Error; err != nil { + return snapshot, err + } + for _, tool := range workbenchTools { + if tool.IconResourceID != "" && slices.Contains(resourceIDs, tool.IconResourceID) { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "工作台工具图标", ID: tool.ID, Title: tool.Name, ResourceID: tool.IconResourceID}) + } + if tool.CoverResourceID != "" && slices.Contains(resourceIDs, tool.CoverResourceID) { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "工作台工具封面", ID: tool.ID, Title: tool.Name, ResourceID: tool.CoverResourceID}) + } + } + var assets []model.Asset assetQuery := r.db.Where("user_id = ?", userID) if len(excludingAssetIDs) > 0 { diff --git a/backend/internal/repository/workbench.go b/backend/internal/repository/workbench.go new file mode 100644 index 000000000..826a9c197 --- /dev/null +++ b/backend/internal/repository/workbench.go @@ -0,0 +1,129 @@ +package repository + +import ( + "yingce/backend/internal/model" + + "gorm.io/gorm" +) + +func (r *Repository) WorkbenchTools(kind string, enabledOnly bool) ([]model.WorkbenchTool, error) { + var items []model.WorkbenchTool + query := r.db.Model(&model.WorkbenchTool{}) + if kind != "" { + query = query.Where("kind = ?", kind) + } + if enabledOnly { + query = query.Where("enabled = ?", true) + } + err := query.Order("kind asc, sort_order asc, name asc, id asc").Find(&items).Error + return items, err +} + +func (r *Repository) WorkbenchTool(id string) (*model.WorkbenchTool, error) { + var item model.WorkbenchTool + if err := r.db.First(&item, "id = ?", id).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *Repository) CreateWorkbenchTool(item *model.WorkbenchTool) error { + return r.db.Create(item).Error +} +func (r *Repository) SaveWorkbenchTool(item *model.WorkbenchTool) error { return r.db.Save(item).Error } + +func (r *Repository) SaveWorkbenchToolWithPresets(item *model.WorkbenchTool, presets *[]model.WorkbenchPreset, create bool) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var err error + if create { + err = tx.Create(item).Error + } else { + err = tx.Save(item).Error + } + if err != nil || presets == nil { + return err + } + ids := make([]string, 0, len(*presets)) + for index := range *presets { + preset := &(*presets)[index] + ids = append(ids, preset.ID) + if err := tx.Save(preset).Error; err != nil { + return err + } + } + // Omitted presets remain available to task history but leave the public catalog. + query := tx.Model(&model.WorkbenchPreset{}).Where("tool_id = ? AND enabled = ?", item.ID, true) + if len(ids) > 0 { + query = query.Where("id NOT IN ?", ids) + } + return query.Updates(map[string]any{"enabled": false, "updated_by": item.UpdatedBy, "updated_at": item.UpdatedAt, "version": gorm.Expr("version + 1")}).Error + }) +} + +func (r *Repository) WorkbenchPresets(toolID string, enabledOnly bool) ([]model.WorkbenchPreset, error) { + var items []model.WorkbenchPreset + query := r.db.Model(&model.WorkbenchPreset{}) + if toolID != "" { + query = query.Where("tool_id = ?", toolID) + } + if enabledOnly { + query = query.Where("enabled = ?", true) + } + err := query.Order("sort_order asc, name asc, id asc").Find(&items).Error + return items, err +} + +func (r *Repository) WorkbenchPreset(id string) (*model.WorkbenchPreset, error) { + var item model.WorkbenchPreset + if err := r.db.First(&item, "id = ?", id).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *Repository) CreateWorkbenchPreset(item *model.WorkbenchPreset) error { + return r.db.Create(item).Error +} +func (r *Repository) SaveWorkbenchPreset(item *model.WorkbenchPreset) error { + return r.db.Save(item).Error +} + +func (r *Repository) WorkbenchToolAndPreset(toolID, presetID string, publishedOnly bool) (*model.WorkbenchTool, *model.WorkbenchPreset, error) { + var tool model.WorkbenchTool + query := r.db.Where("id = ?", toolID) + if publishedOnly { + query = query.Where("enabled = ?", true) + } + if err := query.First(&tool).Error; err != nil { + return nil, nil, err + } + if presetID == "" { + return &tool, nil, nil + } + var preset model.WorkbenchPreset + query = r.db.Where("id = ? AND tool_id = ?", presetID, toolID) + if publishedOnly { + query = query.Where("enabled = ?", true) + } + if err := query.First(&preset).Error; err != nil { + return nil, nil, err + } + return &tool, &preset, nil +} + +func (r *Repository) WorkbenchEnabledPresetsForTools(toolIDs []string) (map[string][]model.WorkbenchPreset, error) { + result := make(map[string][]model.WorkbenchPreset, len(toolIDs)) + if len(toolIDs) == 0 { + return result, nil + } + var presets []model.WorkbenchPreset + if err := r.db.Where("tool_id IN ? AND enabled = ?", toolIDs, true).Order("sort_order asc, name asc, id asc").Find(&presets).Error; err != nil { + return nil, err + } + for _, preset := range presets { + result[preset.ToolID] = append(result[preset.ToolID], preset) + } + return result, nil +} + +var _ = gorm.ErrRecordNotFound diff --git a/backend/internal/repository/workbench_tasks.go b/backend/internal/repository/workbench_tasks.go new file mode 100644 index 000000000..fcb04e608 --- /dev/null +++ b/backend/internal/repository/workbench_tasks.go @@ -0,0 +1,23 @@ +package repository + +import ( + "yingce/backend/internal/model" +) + +type TaskPage struct { + Tasks []model.Task `json:"tasks"` + NextCursor string `json:"nextCursor,omitempty"` +} + +func (r *Repository) WorkbenchTasks(userID, kind string, activeOnly bool, cursor string, limit int) (TaskPage, error) { + if limit < 1 || limit > 100 { limit = 30 } + if kind != model.WorkbenchKindImage && kind != model.WorkbenchKindVideo { return TaskPage{}, nil } + query := r.db.Model(&model.Task{}).Where("user_id = ? AND workbench_kind = ?", userID, kind) + if activeOnly { query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) } + if cursor != "" { query = query.Where("id < ?", cursor) } + var tasks []model.Task + if err := query.Order("created_at desc, id desc").Limit(limit + 1).Find(&tasks).Error; err != nil { return TaskPage{}, err } + page := TaskPage{Tasks: tasks} + if len(tasks) > limit { page.Tasks = tasks[:limit]; page.NextCursor = page.Tasks[len(page.Tasks)-1].ID } + return page, nil +} diff --git a/backend/internal/repository/workbench_tasks_filter_test.go b/backend/internal/repository/workbench_tasks_filter_test.go new file mode 100644 index 000000000..76b3c4497 --- /dev/null +++ b/backend/internal/repository/workbench_tasks_filter_test.go @@ -0,0 +1,63 @@ +package repository + +import ( + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "yingce/backend/internal/model" +) + +func TestTasksWithWorkbenchKindRequiresMatchingTaskType(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Task{}); err != nil { + t.Fatal(err) + } + + legacyImage := `{"metadata":{"source":"workbench","workbenchKind":"image"}}` + legacyVideo := `{"metadata":{"source":"workbench","workbenchKind":"video"}}` + createdAt := time.Now() + tasks := []model.Task{ + {ID: "image-column", UserID: "user", Type: "canvas_image", WorkbenchKind: "image", InputJSON: `{}`, CreatedAt: createdAt}, + {ID: "image-legacy", UserID: "user", Type: "canvas_image", InputJSON: legacyImage, CreatedAt: createdAt.Add(time.Second)}, + {ID: "video-column", UserID: "user", Type: "canvas_video", WorkbenchKind: "video", InputJSON: `{}`, CreatedAt: createdAt.Add(2 * time.Second)}, + {ID: "video-legacy", UserID: "user", Type: "canvas_video", InputJSON: legacyVideo, CreatedAt: createdAt.Add(3 * time.Second)}, + {ID: "wrong-column-kind", UserID: "user", Type: "canvas_video", WorkbenchKind: "image", InputJSON: `{}`, CreatedAt: createdAt.Add(4 * time.Second)}, + {ID: "wrong-legacy-type", UserID: "user", Type: "canvas_video", InputJSON: legacyImage, CreatedAt: createdAt.Add(5 * time.Second)}, + {ID: "wrong-source", UserID: "user", Type: "canvas_image", InputJSON: `{"metadata":{"source":"canvas","workbenchKind":"image"}}`, CreatedAt: createdAt.Add(6 * time.Second)}, + {ID: "other-user", UserID: "other", Type: "canvas_image", InputJSON: legacyImage, CreatedAt: createdAt.Add(7 * time.Second)}, + } + if err := db.Create(&tasks).Error; err != nil { + t.Fatal(err) + } + + repo := New(db) + for _, tc := range []struct { + kind string + want map[string]bool + }{ + {kind: "image", want: map[string]bool{"image-column": true, "image-legacy": true}}, + {kind: "video", want: map[string]bool{"video-column": true, "video-legacy": true}}, + } { + got, err := repo.TasksWithWorkbenchKind("user", 50, "", false, tc.kind) + if err != nil { + t.Fatalf("list %s tasks: %v", tc.kind, err) + } + if len(got) != len(tc.want) { + t.Fatalf("%s task IDs = %#v, want %d tasks", tc.kind, got, len(tc.want)) + } + for _, task := range got { + if !tc.want[task.ID] { + t.Errorf("%s list included unexpected task %q (type=%q kind=%q)", tc.kind, task.ID, task.Type, task.WorkbenchKind) + } + delete(tc.want, task.ID) + } + if len(tc.want) != 0 { + t.Errorf("%s list omitted expected task IDs: %#v", tc.kind, tc.want) + } + } +} diff --git a/backend/internal/service/aliases_consts.go b/backend/internal/service/aliases_consts.go index bc1e62d3e..028a7ddd5 100644 --- a/backend/internal/service/aliases_consts.go +++ b/backend/internal/service/aliases_consts.go @@ -39,12 +39,14 @@ const ( FeatureCredits = app.FeatureCredits FeatureCustomChannels = app.FeatureCustomChannels FeatureFrontendModels = app.FeatureFrontendModels + FeatureImageWorkbench = app.FeatureImageWorkbench FeaturePluginCenter = app.FeaturePluginCenter FeatureShortDrama = app.FeatureShortDrama FeatureSystemPlugins = app.FeatureSystemPlugins FeatureInspirationSources = app.FeatureInspirationSources FeatureTaskCenter = app.FeatureTaskCenter FeatureTimelineTranscription = app.FeatureTimelineTranscription + FeatureVideoWorkbench = app.FeatureVideoWorkbench ModelCatalogSourceSystem = app.ModelCatalogSourceSystem PaymentPluginAlipayPage = app.PaymentPluginAlipayPage PaymentPluginWeChatNative = app.PaymentPluginWeChatNative diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 7438f20f0..1f4a43c08 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -2,6 +2,11 @@ package service import "yingce/backend/internal/app" +const ( + WorkbenchKindImage = app.WorkbenchKindImage + WorkbenchKindVideo = app.WorkbenchKindVideo +) + type ( PaymentOrderQuery = app.PaymentOrderQuery PaymentReconciliationQuery = app.PaymentReconciliationQuery @@ -340,6 +345,11 @@ type ( UpdateProjectUnitRequest = app.UpdateProjectUnitRequest UpdateUserRequest = app.UpdateUserRequest UpdateWorkflowStepRequest = app.UpdateWorkflowStepRequest + WorkbenchToolInput = app.WorkbenchToolInput + WorkbenchPresetInput = app.WorkbenchPresetInput + WorkbenchToolView = app.WorkbenchToolView + WorkbenchPresetView = app.WorkbenchPresetView + WorkbenchTaskInput = app.WorkbenchTaskInput UserAnnouncementFeed = app.UserAnnouncementFeed UserAssetPage = app.UserAssetPage UserAssetPageFilter = app.UserAssetPageFilter diff --git a/backend/internal/workbench/catalog.go b/backend/internal/workbench/catalog.go new file mode 100644 index 000000000..34ca5b140 --- /dev/null +++ b/backend/internal/workbench/catalog.go @@ -0,0 +1,182 @@ +package workbench + +import ( + "encoding/json" + "fmt" + "strings" +) + +// InputContract describes the media and text inputs a workbench tool expects. +// It is stored inside capability JSON so the catalog and task admission share one contract. +type InputContract struct { + PromptRequired bool `json:"promptRequired"` + MinTextReferences int `json:"minTextReferences"` + Slots []ReferenceSlot `json:"slots"` +} + +type ReferenceSlot struct { + Key string `json:"key"` + Name string `json:"name"` + MediaTypes []string `json:"mediaTypes"` + ResourceKind string `json:"resourceKind"` + MaxCount int `json:"maxCount"` + Required bool `json:"required"` + MinCount int `json:"minCount"` + CoverResourceID string `json:"coverResourceId,omitempty"` + SampleResourceID string `json:"sampleResourceId,omitempty"` +} + +type ResultExample struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + ImageResourceIDs []string `json:"imageResourceIds,omitempty"` + VideoResourceID string `json:"videoResourceId,omitempty"` +} + +type catalogConfig struct { + InputContract InputContract `json:"inputContract"` + SearchKeywords []string `json:"searchKeywords,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Recommended bool `json:"recommended"` + RecommendationOrder int `json:"recommendationOrder"` + InputHint string `json:"inputHint,omitempty"` + ResultExample ResultExample `json:"resultExample,omitempty"` +} + +func parseCatalogConfig(capabilityJSON, guideJSON string) (catalogConfig, error) { + config := catalogConfig{InputContract: InputContract{PromptRequired: true, Slots: []ReferenceSlot{}}} + capability := map[string]json.RawMessage{} + if strings.TrimSpace(capabilityJSON) != "" && strings.TrimSpace(capabilityJSON) != "{}" { + if err := json.Unmarshal([]byte(capabilityJSON), &capability); err != nil { + return config, fmt.Errorf("capability: %w", err) + } + } + if raw, ok := capability["inputContract"]; ok { + if err := json.Unmarshal(raw, &config.InputContract); err != nil { + return config, fmt.Errorf("inputContract: %w", err) + } + } + if raw, ok := capability["searchKeywords"]; ok { + _ = json.Unmarshal(raw, &config.SearchKeywords) + } + if raw, ok := capability["surfaces"]; ok { + _ = json.Unmarshal(raw, &config.Surfaces) + } + if raw, ok := capability["recommended"]; ok { + _ = json.Unmarshal(raw, &config.Recommended) + } + if raw, ok := capability["recommendationOrder"]; ok { + _ = json.Unmarshal(raw, &config.RecommendationOrder) + } + guide := map[string]json.RawMessage{} + if strings.TrimSpace(guideJSON) != "" && strings.TrimSpace(guideJSON) != "{}" { + if err := json.Unmarshal([]byte(guideJSON), &guide); err != nil { + return config, fmt.Errorf("guide: %w", err) + } + } + if raw, ok := guide["inputHint"]; ok { + _ = json.Unmarshal(raw, &config.InputHint) + } + if raw, ok := guide["resultExample"]; ok { + if err := json.Unmarshal(raw, &config.ResultExample); err != nil { + return config, fmt.Errorf("resultExample: %w", err) + } + } + return config, nil +} + +func normalizeCatalogConfig(config catalogConfig) (catalogConfig, error) { + if config.InputContract.MinTextReferences < 0 || config.InputContract.MinTextReferences > 20 { + return config, fmt.Errorf("文本参考数量无效") + } + if len(config.InputContract.Slots) > 20 { + return config, fmt.Errorf("输入素材槽位不能超过 20 个") + } + seen := map[string]bool{} + for index := range config.InputContract.Slots { + slot := &config.InputContract.Slots[index] + slot.Key = strings.TrimSpace(slot.Key) + slot.Name = strings.TrimSpace(slot.Name) + slot.ResourceKind = strings.TrimSpace(slot.ResourceKind) + if slot.ResourceKind == "" { + slot.ResourceKind = "asset" + } + if slot.Key == "" || slot.Name == "" || seen[slot.Key] { + return config, fmt.Errorf("输入素材槽位名称或标识无效") + } + seen[slot.Key] = true + if len(slot.MediaTypes) == 0 { + return config, fmt.Errorf("输入素材槽位必须指定媒体类型") + } + for mediaIndex, mediaType := range slot.MediaTypes { + mediaType = strings.TrimSpace(mediaType) + if mediaType != "image" && mediaType != "video" && mediaType != "audio" { + return config, fmt.Errorf("输入素材槽位媒体类型无效") + } + slot.MediaTypes[mediaIndex] = mediaType + } + if slot.MaxCount < 1 || slot.MaxCount > 20 || slot.MinCount < 0 || slot.MinCount > slot.MaxCount { + return config, fmt.Errorf("输入素材槽位数量范围无效") + } + if slot.Required && slot.MinCount < 1 { + slot.MinCount = 1 + } + slot.CoverResourceID = strings.TrimSpace(slot.CoverResourceID) + slot.SampleResourceID = strings.TrimSpace(slot.SampleResourceID) + } + config.SearchKeywords = cleanStrings(config.SearchKeywords, 20) + config.Surfaces = cleanStrings(config.Surfaces, 8) + config.InputHint = strings.TrimSpace(config.InputHint) + config.ResultExample.Title = strings.TrimSpace(config.ResultExample.Title) + config.ResultExample.Description = strings.TrimSpace(config.ResultExample.Description) + config.ResultExample.ImageResourceIDs = cleanStrings(config.ResultExample.ImageResourceIDs, 8) + config.ResultExample.VideoResourceID = strings.TrimSpace(config.ResultExample.VideoResourceID) + if config.RecommendationOrder < 0 || config.RecommendationOrder > 1_000_000 { + return config, fmt.Errorf("推荐排序值无效") + } + return config, nil +} + +func validCategoryID(categoryID string) bool { + switch categoryID { + case "recommended", "people", "ecommerce", "product", "drama-character", "drama-scene", "storyboard", "short-video", "scene", "enhancement": + return true + default: + return false + } +} +func cleanStrings(values []string, limit int) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] || len(result) >= limit { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func catalogResourceIDs(config catalogConfig) []struct{ id, kind string } { + result := make([]struct{ id, kind string }, 0) + seen := map[string]bool{} + appendResource := func(id, kind string) { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + return + } + seen[id] = true + result = append(result, struct{ id, kind string }{id: id, kind: kind}) + } + for _, slot := range config.InputContract.Slots { + appendResource(slot.CoverResourceID, "image") + appendResource(slot.SampleResourceID, "image") + } + for _, id := range config.ResultExample.ImageResourceIDs { + appendResource(id, "image") + } + appendResource(config.ResultExample.VideoResourceID, "video") + return result +} diff --git a/backend/internal/workbench/service.go b/backend/internal/workbench/service.go new file mode 100644 index 000000000..700758a35 --- /dev/null +++ b/backend/internal/workbench/service.go @@ -0,0 +1,603 @@ +package workbench + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" +) + +const ( + KindImage = model.WorkbenchKindImage + KindVideo = model.WorkbenchKindVideo +) + +type AdminGate interface { + RequireAdmin(*model.User) error + AppendAudit(*model.User, string, string, string, string, any) error +} + +type ResourceAccessIssuer interface { + WorkbenchResourceAccess(resourceID string) (string, error) +} + +type Service struct { + repo *repository.Repository + admin AdminGate + access ResourceAccessIssuer +} + +func New(repo *repository.Repository, admin AdminGate, access ResourceAccessIssuer) *Service { + return &Service{repo: repo, admin: admin, access: access} +} + +type ToolInput struct { + Kind string `json:"kind"` + CategoryID string `json:"categoryId"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` + Description string `json:"description"` + IconResourceID string `json:"iconResourceId"` + CoverResourceID string `json:"coverResourceId"` + SortOrder int `json:"sortOrder"` + Capability json.RawMessage `json:"capability"` + Guide json.RawMessage `json:"guide"` + Enabled bool `json:"enabled"` + Presets *[]ToolPresetInput `json:"presets,omitempty"` +} + +// ToolPresetInput allows the editor to save a tool and its presets atomically. +type ToolPresetInput struct { + ID string `json:"id,omitempty"` + PresetInput +} + +type PresetInput struct { + ToolID string `json:"toolId"` + Name string `json:"name"` + Summary string `json:"summary"` + PromptTemplate string `json:"promptTemplate"` + DefaultParameters json.RawMessage `json:"defaultParameters"` + SortOrder int `json:"sortOrder"` + Enabled bool `json:"enabled"` +} + +type ToolView struct { + ID string `json:"id"` + Kind string `json:"kind"` + CategoryID string `json:"categoryId"` + Name string `json:"name"` + Subtitle string `json:"subtitle"` + Description string `json:"description"` + IconResourceID string `json:"iconResourceId,omitempty"` + CoverResourceID string `json:"coverResourceId,omitempty"` + IconURL string `json:"iconUrl,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + SortOrder int `json:"sortOrder"` + Capability json.RawMessage `json:"capability,omitempty"` + Guide json.RawMessage `json:"guide,omitempty"` + InputHint string `json:"inputHint,omitempty"` + SearchKeywords []string `json:"searchKeywords,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Recommended bool `json:"recommended"` + RecommendationOrder int `json:"recommendationOrder"` + InputContract InputContractView `json:"inputContract"` + ResultExample ResultExampleView `json:"resultExample"` + Enabled bool `json:"enabled"` + Version int `json:"version"` + Presets []PresetView `json:"presets"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type InputContractView struct { + PromptRequired bool `json:"promptRequired"` + MinTextReferences int `json:"minTextReferences"` + Slots []ReferenceSlotView `json:"slots"` +} + +type ReferenceSlotView struct { + Key string `json:"key"` + Name string `json:"name"` + MediaTypes []string `json:"mediaTypes"` + ResourceKind string `json:"resourceKind"` + MaxCount int `json:"maxCount"` + Required bool `json:"required"` + MinCount int `json:"minCount"` + CoverResourceID string `json:"coverResourceId,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` + SampleResourceID string `json:"sampleResourceId,omitempty"` + SampleURL string `json:"sampleUrl,omitempty"` +} + +type ResultExampleView struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + ImageResourceIDs []string `json:"imageResourceIds,omitempty"` + ImageURLs []string `json:"imageUrls,omitempty"` + VideoResourceID string `json:"videoResourceId,omitempty"` + VideoURL string `json:"videoUrl,omitempty"` +} + +type PresetView struct { + ID string `json:"id"` + ToolID string `json:"toolId"` + Name string `json:"name"` + Summary string `json:"summary"` + PromptTemplate string `json:"promptTemplate"` + DefaultParameters json.RawMessage `json:"defaultParameters,omitempty"` + SortOrder int `json:"sortOrder"` + Enabled bool `json:"enabled"` + Version int `json:"version"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (s *Service) PublicTools(kind string) ([]ToolView, error) { + if !validKind(kind) { + return nil, kernel.BadAuthRequest("工作台类型无效") + } + tools, err := s.repo.WorkbenchTools(kind, true) + if err != nil { + return nil, err + } + presetsByTool, err := s.repo.WorkbenchEnabledPresetsForTools(toolIDs(tools)) + if err != nil { + return nil, err + } + views := make([]ToolView, 0, len(tools)) + for _, item := range tools { + view := toolView(item, presetsByTool[item.ID]) + if s.access != nil { + if err := s.attachToolMedia(&view); err != nil { + return nil, err + } + } + views = append(views, view) + } + return views, nil +} + +func (s *Service) AdminTools(actor *model.User, kind string) ([]ToolView, error) { + if err := s.requireAdmin(actor); err != nil { + return nil, err + } + if kind != "" && !validKind(kind) { + return nil, kernel.BadAuthRequest("工作台类型无效") + } + tools, err := s.repo.WorkbenchTools(kind, false) + if err != nil { + return nil, err + } + views := make([]ToolView, 0, len(tools)) + for _, item := range tools { + presets, err := s.repo.WorkbenchPresets(item.ID, false) + if err != nil { + return nil, err + } + view := toolView(item, presets) + view.IconURL, view.CoverURL = "", "" + views = append(views, view) + } + return views, nil +} + +func (s *Service) CreateTool(actor *model.User, req ToolInput) (*ToolView, error) { + if err := s.requireAdmin(actor); err != nil { + return nil, err + } + item, err := normalizeTool(req) + if err != nil { + return nil, err + } + if err := s.validateToolResources(actor.ID, item.IconResourceID, item.CoverResourceID, item.CapabilityJSON, item.GuideJSON); err != nil { + return nil, err + } + item.ID, item.CreatedBy, item.UpdatedBy = kernel.NewID(), actor.ID, actor.ID + item.Version, item.CreatedAt, item.UpdatedAt = 1, time.Now(), time.Now() + presets, err := s.normalizeToolPresets(actor.ID, item.ID, req.Presets) + if err != nil { + return nil, err + } + if err := s.repo.SaveWorkbenchToolWithPresets(item, presets, true); err != nil { + return nil, err + } + if err := s.audit(actor, "workbench_tool.create", item.ID, "创建工作台工具", map[string]any{"kind": item.Kind, "enabled": item.Enabled}); err != nil { + return nil, err + } + return s.adminToolView(item) +} + +func (s *Service) UpdateTool(actor *model.User, id string, req ToolInput) (*ToolView, error) { + if err := s.requireAdmin(actor); err != nil { + return nil, err + } + before, err := s.repo.WorkbenchTool(id) + if err != nil { + return nil, err + } + item, err := normalizeTool(req) + if err != nil { + return nil, err + } + if item.Kind != before.Kind { + return nil, kernel.BadAuthRequest("工具类型创建后不可更改") + } + if err := s.validateToolResources(actor.ID, item.IconResourceID, item.CoverResourceID, item.CapabilityJSON, item.GuideJSON); err != nil { + return nil, err + } + item.ID, item.CreatedBy, item.CreatedAt = before.ID, before.CreatedBy, before.CreatedAt + item.UpdatedBy, item.UpdatedAt, item.Version = actor.ID, time.Now(), before.Version+1 + presets, err := s.normalizeToolPresets(actor.ID, item.ID, req.Presets) + if err != nil { + return nil, err + } + if err := s.repo.SaveWorkbenchToolWithPresets(item, presets, false); err != nil { + return nil, err + } + if err := s.audit(actor, "workbench_tool.update", id, "更新工作台工具", map[string]any{"beforeVersion": before.Version, "version": item.Version, "enabled": item.Enabled}); err != nil { + return nil, err + } + return s.adminToolView(item) +} + +func (s *Service) SetToolEnabled(actor *model.User, id string, enabled bool) error { + if err := s.requireAdmin(actor); err != nil { + return err + } + item, err := s.repo.WorkbenchTool(id) + if err != nil { + return err + } + if enabled { + if strings.TrimSpace(item.Name) == "" || !validKind(item.Kind) { + return kernel.BadAuthRequest("工具缺少有效名称或类型") + } + if err := s.validateToolResources(actor.ID, item.IconResourceID, item.CoverResourceID, item.CapabilityJSON, item.GuideJSON); err != nil { + return err + } + } + before := item.Enabled + item.Enabled, item.UpdatedBy, item.UpdatedAt, item.Version = enabled, actor.ID, time.Now(), item.Version+1 + if err := s.repo.SaveWorkbenchTool(item); err != nil { + return err + } + return s.audit(actor, "workbench_tool.publish", id, map[bool]string{true: "发布工作台工具", false: "下线工作台工具"}[enabled], map[string]any{"before": before, "enabled": enabled, "version": item.Version}) +} + +func (s *Service) CreatePreset(actor *model.User, req PresetInput) (*PresetView, error) { + if err := s.requireAdmin(actor); err != nil { + return nil, err + } + tool, err := s.repo.WorkbenchTool(strings.TrimSpace(req.ToolID)) + if err != nil { + return nil, err + } + item, err := normalizePreset(req) + if err != nil { + return nil, err + } + item.ID, item.CreatedBy, item.UpdatedBy = kernel.NewID(), actor.ID, actor.ID + item.Version, item.CreatedAt, item.UpdatedAt = 1, time.Now(), time.Now() + if err := s.repo.CreateWorkbenchPreset(item); err != nil { + return nil, err + } + if err := s.audit(actor, "workbench_preset.create", item.ID, "创建工作台提示词预设", map[string]any{"toolId": tool.ID, "enabled": item.Enabled}); err != nil { + return nil, err + } + view := presetView(*item) + return &view, nil +} + +func (s *Service) UpdatePreset(actor *model.User, id string, req PresetInput) (*PresetView, error) { + if err := s.requireAdmin(actor); err != nil { + return nil, err + } + before, err := s.repo.WorkbenchPreset(id) + if err != nil { + return nil, err + } + item, err := normalizePreset(req) + if err != nil { + return nil, err + } + if item.ToolID != before.ToolID { + return nil, kernel.BadAuthRequest("预设所属工具不可更改") + } + item.ID, item.CreatedBy, item.CreatedAt = before.ID, before.CreatedBy, before.CreatedAt + item.Version, item.UpdatedBy, item.UpdatedAt = before.Version+1, actor.ID, time.Now() + if err := s.repo.SaveWorkbenchPreset(item); err != nil { + return nil, err + } + if err := s.audit(actor, "workbench_preset.update", id, "更新工作台提示词预设", map[string]any{"beforeVersion": before.Version, "version": item.Version, "enabled": item.Enabled}); err != nil { + return nil, err + } + view := presetView(*item) + return &view, nil +} + +func (s *Service) SetPresetEnabled(actor *model.User, id string, enabled bool) error { + if err := s.requireAdmin(actor); err != nil { + return err + } + item, err := s.repo.WorkbenchPreset(id) + if err != nil { + return err + } + if _, err := s.repo.WorkbenchTool(item.ToolID); err != nil { + return err + } + before := item.Enabled + item.Enabled, item.Version, item.UpdatedBy, item.UpdatedAt = enabled, item.Version+1, actor.ID, time.Now() + if err := s.repo.SaveWorkbenchPreset(item); err != nil { + return err + } + return s.audit(actor, "workbench_preset.publish", id, map[bool]string{true: "启用工作台预设", false: "停用工作台预设"}[enabled], map[string]any{"before": before, "enabled": enabled, "version": item.Version}) +} + +func (s *Service) adminToolView(item *model.WorkbenchTool) (*ToolView, error) { + presets, err := s.repo.WorkbenchPresets(item.ID, false) + if err != nil { + return nil, err + } + view := toolView(*item, presets) + view.IconURL, view.CoverURL = "", "" + return &view, nil +} + +func (s *Service) requireAdmin(actor *model.User) error { + if s == nil || s.admin == nil { + return errors.New("工作台管理权限未配置") + } + return s.admin.RequireAdmin(actor) +} +func (s *Service) audit(actor *model.User, action, id, summary string, data any) error { + if s == nil || s.admin == nil { + return errors.New("工作台审计未配置") + } + return s.admin.AppendAudit(actor, action, "workbench", id, summary, data) +} + +func (s *Service) normalizeToolPresets(actorID, toolID string, inputs *[]ToolPresetInput) (*[]model.WorkbenchPreset, error) { + if inputs == nil { + return nil, nil + } + if len(*inputs) > 50 { + return nil, kernel.BadAuthRequest("每个工具最多配置 50 条预设") + } + items := make([]model.WorkbenchPreset, 0, len(*inputs)) + seen := map[string]bool{} + for _, input := range *inputs { + input.ToolID = toolID + item, err := normalizePreset(input.PresetInput) + if err != nil { + return nil, err + } + item.ID = strings.TrimSpace(input.ID) + if item.ID == "" { + item.ID = kernel.NewID() + } else { + before, err := s.repo.WorkbenchPreset(item.ID) + if err != nil || before.ToolID != toolID { + return nil, kernel.BadAuthRequest("预设不属于当前工具") + } + item.CreatedBy, item.CreatedAt, item.Version = before.CreatedBy, before.CreatedAt, before.Version + } + if seen[item.ID] { + return nil, kernel.BadAuthRequest("预设重复") + } + seen[item.ID] = true + if item.CreatedBy == "" { + item.CreatedBy, item.CreatedAt = actorID, time.Now() + } + item.UpdatedBy, item.UpdatedAt, item.Version = actorID, time.Now(), item.Version+1 + items = append(items, *item) + } + return &items, nil +} + +func (s *Service) validateToolResources(adminID, iconID, coverID, capabilityJSON, guideJSON string) error { + seen := map[string]bool{} + for _, id := range []string{iconID, coverID} { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + seen[id] = true + resource, err := s.repo.ResourceForUser(adminID, id) + if err != nil || resource.Status != model.ResourceStatusReady || resource.Kind != "image" { + return kernel.BadAuthRequest("工具图标和封面必须引用当前管理员已就绪的图片资源") + } + } + config, err := parseCatalogConfig(capabilityJSON, guideJSON) + if err != nil { + return kernel.BadAuthRequest("工具输入配置无效") + } + for _, reference := range catalogResourceIDs(config) { + if seen[reference.id] { + continue + } + seen[reference.id] = true + resource, resourceErr := s.repo.ResourceForUser(adminID, reference.id) + if resourceErr != nil || resource.Status != model.ResourceStatusReady || resource.Kind != reference.kind { + return kernel.BadAuthRequest("工具输入槽位和效果示例必须引用当前管理员已就绪的对应媒体") + } + } + return nil +} + +func (s *Service) attachToolMedia(view *ToolView) error { + if s.access == nil { + return nil + } + if view.IconResourceID != "" { + url, err := s.access.WorkbenchResourceAccess(view.IconResourceID) + if err != nil { + return err + } + view.IconURL = url + } + if view.CoverResourceID != "" { + url, err := s.access.WorkbenchResourceAccess(view.CoverResourceID) + if err != nil { + return err + } + view.CoverURL = url + } + for index := range view.InputContract.Slots { + slot := &view.InputContract.Slots[index] + if slot.CoverResourceID != "" { + if url, err := s.access.WorkbenchResourceAccess(slot.CoverResourceID); err == nil { + slot.CoverURL = url + } + } + if slot.SampleResourceID != "" { + if url, err := s.access.WorkbenchResourceAccess(slot.SampleResourceID); err == nil { + slot.SampleURL = url + } + } + } + for _, resourceID := range view.ResultExample.ImageResourceIDs { + if url, err := s.access.WorkbenchResourceAccess(resourceID); err == nil { + view.ResultExample.ImageURLs = append(view.ResultExample.ImageURLs, url) + } + } + if view.ResultExample.VideoResourceID != "" { + if url, err := s.access.WorkbenchResourceAccess(view.ResultExample.VideoResourceID); err == nil { + view.ResultExample.VideoURL = url + } + } + return nil +} + +func normalizeTool(req ToolInput) (*model.WorkbenchTool, error) { + item := &model.WorkbenchTool{ + Kind: strings.TrimSpace(req.Kind), CategoryID: strings.TrimSpace(req.CategoryID), Name: strings.TrimSpace(req.Name), + Subtitle: strings.TrimSpace(req.Subtitle), Description: strings.TrimSpace(req.Description), IconResourceID: strings.TrimSpace(req.IconResourceID), + CoverResourceID: strings.TrimSpace(req.CoverResourceID), SortOrder: req.SortOrder, Enabled: req.Enabled, + } + if !validKind(item.Kind) { + return nil, kernel.BadAuthRequest("工作台类型只能是 image 或 video") + } + if !validCategoryID(item.CategoryID) { + return nil, kernel.BadAuthRequest("工具分类无效") + } + if item.Name == "" || utf8.RuneCountInString(item.Name) > 120 || utf8.RuneCountInString(item.CategoryID) > 64 || utf8.RuneCountInString(item.Subtitle) > 240 || utf8.RuneCountInString(item.Description) > 12000 { + return nil, kernel.BadAuthRequest("工具名称、分类或说明长度无效") + } + if item.SortOrder < 0 || item.SortOrder > 1_000_000 { + return nil, kernel.BadAuthRequest("工具排序值无效") + } + var err error + item.CapabilityJSON, err = optionalJSONObject(req.Capability) + if err != nil { + return nil, fmt.Errorf("capability: %w", err) + } + item.GuideJSON, err = optionalJSONObject(req.Guide) + if err != nil { + return nil, fmt.Errorf("guide: %w", err) + } + config, err := parseCatalogConfig(item.CapabilityJSON, item.GuideJSON) + if err != nil { + return nil, kernel.BadAuthRequest("工具预设结构无效") + } + config, err = normalizeCatalogConfig(config) + if err != nil { + return nil, kernel.BadAuthRequest(err.Error()) + } + capability := map[string]any{} + guide := map[string]any{} + _ = json.Unmarshal([]byte(item.CapabilityJSON), &capability) + _ = json.Unmarshal([]byte(item.GuideJSON), &guide) + capability["inputContract"] = config.InputContract + capability["searchKeywords"], capability["surfaces"] = config.SearchKeywords, config.Surfaces + capability["recommended"], capability["recommendationOrder"] = config.Recommended, config.RecommendationOrder + guide["inputHint"], guide["resultExample"] = config.InputHint, config.ResultExample + encodedCapability, err := json.Marshal(capability) + if err != nil { + return nil, err + } + encodedGuide, err := json.Marshal(guide) + if err != nil { + return nil, err + } + item.CapabilityJSON, item.GuideJSON = string(encodedCapability), string(encodedGuide) + return item, nil +} + +func normalizePreset(req PresetInput) (*model.WorkbenchPreset, error) { + item := &model.WorkbenchPreset{ + ToolID: strings.TrimSpace(req.ToolID), Name: strings.TrimSpace(req.Name), Summary: strings.TrimSpace(req.Summary), + PromptTemplate: strings.TrimSpace(req.PromptTemplate), SortOrder: req.SortOrder, Enabled: req.Enabled, + } + if item.ToolID == "" || item.Name == "" || item.PromptTemplate == "" || utf8.RuneCountInString(item.Name) > 120 || utf8.RuneCountInString(item.Summary) > 500 || utf8.RuneCountInString(item.PromptTemplate) > 12000 { + return nil, kernel.BadAuthRequest("预设工具、名称、提示词或文本长度无效") + } + if item.SortOrder < 0 || item.SortOrder > 1_000_000 { + return nil, kernel.BadAuthRequest("预设排序值无效") + } + var err error + item.DefaultParamsJSON, err = optionalJSONObject(req.DefaultParameters) + if err != nil { + return nil, fmt.Errorf("defaultParameters: %w", err) + } + return item, nil +} + +func optionalJSONObject(raw json.RawMessage) (string, error) { + if len(raw) == 0 || string(raw) == "null" { + return "{}", nil + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil || value == nil { + return "", kernel.BadAuthRequest("字段必须是 JSON 对象") + } + return string(raw), nil +} + +func toolIDs(items []model.WorkbenchTool) []string { + ids := make([]string, 0, len(items)) + for _, item := range items { + ids = append(ids, item.ID) + } + return ids +} + +func validKind(kind string) bool { return kind == KindImage || kind == KindVideo } + +func toolView(item model.WorkbenchTool, presets []model.WorkbenchPreset) ToolView { + config, _ := parseCatalogConfig(item.CapabilityJSON, item.GuideJSON) + view := ToolView{ + ID: item.ID, Kind: item.Kind, CategoryID: item.CategoryID, Name: item.Name, Subtitle: item.Subtitle, Description: item.Description, + IconResourceID: item.IconResourceID, CoverResourceID: item.CoverResourceID, SortOrder: item.SortOrder, + Capability: json.RawMessage(item.CapabilityJSON), Guide: json.RawMessage(item.GuideJSON), InputHint: config.InputHint, + SearchKeywords: config.SearchKeywords, Surfaces: config.Surfaces, Recommended: config.Recommended, RecommendationOrder: config.RecommendationOrder, + InputContract: inputContractView(config.InputContract), ResultExample: resultExampleView(config.ResultExample), Enabled: item.Enabled, Version: item.Version, UpdatedAt: item.UpdatedAt, + Presets: make([]PresetView, 0, len(presets)), + } + for _, preset := range presets { + view.Presets = append(view.Presets, presetView(preset)) + } + return view +} + +func inputContractView(contract InputContract) InputContractView { + view := InputContractView{PromptRequired: contract.PromptRequired, MinTextReferences: contract.MinTextReferences, Slots: make([]ReferenceSlotView, 0, len(contract.Slots))} + for _, slot := range contract.Slots { + view.Slots = append(view.Slots, ReferenceSlotView{Key: slot.Key, Name: slot.Name, MediaTypes: slot.MediaTypes, ResourceKind: slot.ResourceKind, MaxCount: slot.MaxCount, Required: slot.Required, MinCount: slot.MinCount, CoverResourceID: slot.CoverResourceID, SampleResourceID: slot.SampleResourceID}) + } + return view +} + +func resultExampleView(example ResultExample) ResultExampleView { + return ResultExampleView{Title: example.Title, Description: example.Description, ImageResourceIDs: example.ImageResourceIDs, VideoResourceID: example.VideoResourceID} +} + +func presetView(item model.WorkbenchPreset) PresetView { + return PresetView{ID: item.ID, ToolID: item.ToolID, Name: item.Name, Summary: item.Summary, PromptTemplate: item.PromptTemplate, + DefaultParameters: json.RawMessage(item.DefaultParamsJSON), SortOrder: item.SortOrder, Enabled: item.Enabled, Version: item.Version, UpdatedAt: item.UpdatedAt} +} diff --git a/backend/internal/workbench/service_test.go b/backend/internal/workbench/service_test.go new file mode 100644 index 000000000..cb40a2ed9 --- /dev/null +++ b/backend/internal/workbench/service_test.go @@ -0,0 +1,210 @@ +package workbench + +import ( + "encoding/json" + "strings" + "testing" + + "yingce/backend/internal/model" + "yingce/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type allowAdmin struct{ audited int } + +func (a *allowAdmin) RequireAdmin(user *model.User) error { + if user == nil || user.Role != model.UserRoleAdmin { + return gorm.ErrInvalidData + } + return nil +} +func (a *allowAdmin) AppendAudit(_ *model.User, _, _, _, _ string, _ any) error { + a.audited++ + return nil +} + +type resourceAccessStub struct{} + +func (resourceAccessStub) WorkbenchResourceAccess(id string) (string, error) { + return "/api/public/resources/" + id, nil +} + +func newWorkbenchTest(t *testing.T) (*Service, *gorm.DB, *allowAdmin) { + t.Helper() + dsn := "file:workbench-tests-" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.WorkbenchTool{}, &model.WorkbenchPreset{}, &model.Resource{}); err != nil { + t.Fatal(err) + } + admin := &allowAdmin{} + return New(repository.New(db), admin, resourceAccessStub{}), db, admin +} + +func TestWorkbenchAdminCRUDAuditsAndPublishesSafeDirectory(t *testing.T) { + s, db, audit := newWorkbenchTest(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + resource := model.Resource{ID: "icon", UserID: "admin", Kind: "image", Status: model.ResourceStatusReady} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + tool, err := s.CreateTool(admin, ToolInput{Kind: KindImage, CategoryID: "recommended", Name: "Portrait", IconResourceID: "icon", Capability: json.RawMessage(`{"referenceKinds":["image"]}`), Enabled: true}) + if err != nil { + t.Fatal(err) + } + if tool.IconResourceID != "icon" || tool.IconURL != "" { + t.Fatalf("admin projection mismatch: %#v", tool) + } + preset, err := s.CreatePreset(admin, PresetInput{ToolID: tool.ID, Name: "Studio portrait", PromptTemplate: "Portrait of {{subject}}", Enabled: true}) + if err != nil { + t.Fatal(err) + } + public, err := s.PublicTools(KindImage) + if err != nil { + t.Fatal(err) + } + if len(public) != 1 || public[0].IconResourceID != "icon" || public[0].IconURL != "/api/public/resources/icon" || len(public[0].Presets) != 1 || public[0].Presets[0].ID != preset.ID { + t.Fatalf("public catalog mismatch: %#v", public) + } + if audit.audited != 2 { + t.Fatalf("audits=%d want 2", audit.audited) + } +} + +func TestWorkbenchRejectsNonAdminResourcesAndInvalidPresetParent(t *testing.T) { + s, db, _ := newWorkbenchTest(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + resource := model.Resource{ID: "private-icon", UserID: "other", Kind: "image", Status: model.ResourceStatusReady} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + if _, err := s.CreateTool(admin, ToolInput{Kind: KindVideo, Name: "Video", IconResourceID: "private-icon"}); err == nil { + t.Fatal("expected foreign resource rejection") + } + if _, err := s.CreatePreset(admin, PresetInput{ToolID: "missing", Name: "Preset", PromptTemplate: "prompt"}); err == nil { + t.Fatal("expected missing tool rejection") + } + if _, err := s.CreateTool(&model.User{ID: "user", Role: model.UserRoleUser}, ToolInput{Kind: KindImage, Name: "No"}); err == nil { + t.Fatal("expected non-admin rejection") + } +} + +func TestWorkbenchTaskRejectsForeignResourcesAndPreservesRoleAndPresetSnapshot(t *testing.T) { + s, db, _ := newWorkbenchTest(t) + tool := model.WorkbenchTool{ID: "tool", Kind: KindVideo, Name: "Image to video", Enabled: true, Version: 4, CapabilityJSON: `{"referenceKinds":["image"],"inputContract":{"slots":[{"key":"subject","name":"Subject","mediaTypes":["image"],"maxCount":1,"minCount":1,"required":true}]}}`} + preset := model.WorkbenchPreset{ID: "preset", ToolID: "tool", Name: "Motion", PromptTemplate: "move gently", Enabled: true, Version: 2} + image := model.Resource{ID: "image", UserID: "user", Kind: "image", Status: model.ResourceStatusReady} + foreign := model.Resource{ID: "foreign", UserID: "other", Kind: "image", Status: model.ResourceStatusReady} + for _, item := range []any{&tool, &preset, &image, &foreign} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + request := TaskInput{Kind: KindVideo, ToolID: "tool", PresetID: "preset", Prompt: "A woman walks", Model: "video-model", LogicalModelID: "logical", Parameters: map[string]any{"seconds": 6, "ratio": "16:9", "generateAudio": true}, References: []TaskReference{{ResourceID: "image", Kind: "image", Role: "first_frame", SlotKey: "subject"}}} + resolved, err := s.ResolveTask("user", request) + if err != nil { + t.Fatal(err) + } + if resolved.TaskType != "canvas_video" { + t.Fatalf("task type = %q", resolved.TaskType) + } + metadata := resolved.Input["metadata"].(map[string]any) + if metadata["workbenchToolVersion"] != 4 || metadata["workbenchPresetVersion"] != 2 || metadata["workbenchPresetPrompt"] != "move gently" { + t.Fatalf("missing historical snapshot: %#v", metadata) + } + workbenchSnapshot := metadata["workbench"].(map[string]any) + parameters := workbenchSnapshot["parameters"].(map[string]any) + if parameters["seconds"] != 6 || parameters["ratio"] != "16:9" || parameters["generateAudio"] != true { + t.Fatalf("parameters not snapshotted: %#v", parameters) + } + refs := resolved.Input["referenceImages"].([]map[string]any) + if refs[0]["role"] != "first_frame" || refs[0]["resourceId"] != "image" || refs[0]["storageKey"] != "resource:image" || refs[0]["slotKey"] != "subject" { + t.Fatalf("authorized reference not preserved for provider input: %#v", refs) + } + snapshotRefs := workbenchSnapshot["references"].([]map[string]any) + if len(snapshotRefs) != 1 || snapshotRefs[0]["resourceId"] != "image" || snapshotRefs[0]["role"] != "first_frame" || snapshotRefs[0]["slotKey"] != "subject" { + t.Fatalf("workbench reference snapshot = %#v", snapshotRefs) + } + request.References[0].ResourceID = "foreign" + if _, err := s.ResolveTask("user", request); err == nil { + t.Fatal("expected foreign resource rejection") + } + request.References[0] = TaskReference{ResourceID: "image", Kind: "image", Role: "unexpected"} + if _, err := s.ResolveTask("user", request); err == nil { + t.Fatal("expected unsupported role rejection") + } +} + +func TestWorkbenchTaskRejectsUnknownParametersAndClientConfig(t *testing.T) { + s, db, _ := newWorkbenchTest(t) + tool := model.WorkbenchTool{ID: "tool", Kind: KindImage, Name: "Image", Enabled: true, CapabilityJSON: "{}"} + if err := db.Create(&tool).Error; err != nil { + t.Fatal(err) + } + request := TaskInput{Kind: KindImage, ToolID: "tool", Prompt: "prompt", Model: "image-model", LogicalModelID: "logical", Parameters: map[string]any{"channelId": "attacker"}} + if _, err := s.ResolveTask("user", request); err == nil { + t.Fatal("expected arbitrary field rejection") + } +} + +func TestWorkbenchTaskCanBeCreatedWithoutTool(t *testing.T) { + s, _, _ := newWorkbenchTest(t) + request := TaskInput{Kind: KindImage, Prompt: "A glass greenhouse at sunrise", Model: "image-model", LogicalModelID: "logical", Parameters: map[string]any{"size": "1024x1024", "count": 1}} + resolved, err := s.ResolveTask("user", request) + if err != nil { + t.Fatal(err) + } + if resolved.Tool != nil || resolved.Preset != nil { + t.Fatalf("tool-less task unexpectedly resolved a tool or preset: %#v", resolved) + } + metadata := resolved.Input["metadata"].(map[string]any) + if metadata["source"] != "workbench" || metadata["workbenchKind"] != KindImage { + t.Fatalf("missing workbench task identity: %#v", metadata) + } + if _, ok := metadata["workbenchToolId"]; ok { + t.Fatalf("tool-less task contains a tool id: %#v", metadata) + } + snapshot := metadata["workbench"].(map[string]any) + if snapshot["kind"] != KindImage || snapshot["parameters"].(map[string]any)["count"] != 1 { + t.Fatalf("workbench parameters not snapshotted: %#v", snapshot) + } + if resolved.TaskType != "canvas_image" || resolved.Operation != "image" { + t.Fatalf("unexpected task routing: %#v", resolved) + } +} + +func TestWorkbenchTaskRejectsPresetWithoutTool(t *testing.T) { + s, _, _ := newWorkbenchTest(t) + request := TaskInput{Kind: KindVideo, PresetID: "preset", Prompt: "A quiet city street", Model: "video-model", LogicalModelID: "logical"} + if _, err := s.ResolveTask("user", request); err == nil { + t.Fatal("expected preset without tool rejection") + } +} + +func TestWorkbenchTaskAcceptsSystemChannelWithoutLogicalModel(t *testing.T) { + s, _, _ := newWorkbenchTest(t) + request := TaskInput{Kind: KindImage, Prompt: "A glass greenhouse at sunrise", Model: "image-model", ChannelID: "channel-1", Parameters: map[string]any{"size": "1024x1024", "count": 1}} + resolved, err := s.ResolveTask("user", request) + if err != nil { + t.Fatal(err) + } + if resolved.LogicalModelID != "" || resolved.ChannelID != "channel-1" { + t.Fatalf("channel selection = logical %q channel %q", resolved.LogicalModelID, resolved.ChannelID) + } + config, _ := resolved.Input["config"].(map[string]any) + if config["channelId"] != "channel-1" || config["model"] != "image-model" { + t.Fatalf("provider config = %#v", config) + } + if _, err := s.ResolveTask("user", TaskInput{Kind: KindImage, Prompt: "missing model identity", Model: "image-model"}); err == nil { + t.Fatal("expected rejection when neither logical model nor channel is selected") + } +} diff --git a/backend/internal/workbench/tasks.go b/backend/internal/workbench/tasks.go new file mode 100644 index 000000000..f98a56d64 --- /dev/null +++ b/backend/internal/workbench/tasks.go @@ -0,0 +1,393 @@ +package workbench + +import ( + "encoding/json" + "errors" + "math" + "strconv" + "strings" + + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" +) + +type TaskInput struct { + Kind string `json:"kind"` + ToolID string `json:"toolId"` + PresetID string `json:"presetId,omitempty"` + Workbench *TaskWorkbench `json:"workbench,omitempty"` + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + LogicalModelID string `json:"logicalModelId,omitempty"` + ChannelID string `json:"channelId,omitempty"` + ModelChoice *ModelChoice `json:"modelChoice,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` + References []TaskReference `json:"references,omitempty"` +} + +type TaskWorkbench struct { + Kind string `json:"kind"` + ToolID string `json:"toolId"` + PresetID string `json:"presetId,omitempty"` +} + +type ModelChoice struct { + LogicalModelID string `json:"logicalModelId,omitempty"` + UserChannelID string `json:"userChannelId,omitempty"` + ModelName string `json:"modelName,omitempty"` +} + +type TaskReference struct { + ResourceID string `json:"resourceId"` + Kind string `json:"kind"` + Role string `json:"role,omitempty"` + SlotKey string `json:"slotKey,omitempty"` +} + +type TaskResolution struct { + TaskType string + Operation string + Input map[string]any + Parameters map[string]any + Prompt string + Model string + LogicalModelID string + ChannelID string + Tool *model.WorkbenchTool + Preset *model.WorkbenchPreset +} + +func (s *Service) ResolveTask(userID string, req TaskInput) (*TaskResolution, error) { + toolID := strings.TrimSpace(req.ToolID) + presetID := strings.TrimSpace(req.PresetID) + if req.Workbench != nil && (req.Workbench.Kind != req.Kind || strings.TrimSpace(req.Workbench.ToolID) != toolID || (strings.TrimSpace(req.Workbench.PresetID) != "" && strings.TrimSpace(req.Workbench.PresetID) != presetID)) { + return nil, kernel.BadAuthRequest("工作台任务范围字段不一致") + } + if !validKind(req.Kind) { + return nil, kernel.BadAuthRequest("工作台类型无效") + } + if toolID == "" && presetID != "" { + return nil, kernel.BadAuthRequest("使用提示词预设前必须选择创作工具") + } + var tool *model.WorkbenchTool + var preset *model.WorkbenchPreset + if toolID != "" { + var err error + tool, preset, err = s.repo.WorkbenchToolAndPreset(toolID, presetID, true) + if err != nil { + return nil, kernel.BadAuthRequest("工具或预设未开放") + } + if tool.Kind != req.Kind { + return nil, kernel.BadAuthRequest("所选工具与工作台类型不匹配") + } + } + if strings.TrimSpace(req.Prompt) == "" && (tool == nil || toolRequiresPrompt(tool)) { + return nil, kernel.BadAuthRequest("当前工具需要填写提示词") + } + modelName, logicalModelID, channelID, providerConfig, err := resolveModelInput(req) + if err != nil { + return nil, err + } + if len(providerConfig) == 0 { + return nil, kernel.BadAuthRequest("必须提供可执行模型配置") + } + if containsSensitiveURL(providerConfig) { + return nil, kernel.BadAuthRequest("浏览器不能直接提交模型配置、URL 或凭据") + } + if len(req.References) > 20 { + return nil, kernel.BadAuthRequest("参考素材数量超出限制") + } + parameters, err := normalizeTaskParameters(req.Kind, req.Parameters) + if err != nil { + return nil, err + } + refs := make([]TaskReference, 0, len(req.References)) + frameRoles := map[string]bool{} + for _, ref := range req.References { + if strings.TrimSpace(ref.ResourceID) == "" || !validReferenceKind(ref.Kind) { + return nil, kernel.BadAuthRequest("参考素材无效") + } + role := strings.TrimSpace(ref.Role) + if !validReferenceRole(req.Kind, ref.Kind, role) { + return nil, kernel.BadAuthRequest("参考素材角色无效") + } + if role == "first_frame" || role == "last_frame" { + if frameRoles[role] { + return nil, kernel.BadAuthRequest("首帧和尾帧各只能指定一个素材") + } + frameRoles[role] = true + } + resource, err := s.repo.ResourceForUser(userID, strings.TrimSpace(ref.ResourceID)) + if err != nil || resource.Status != model.ResourceStatusReady || resource.Kind != ref.Kind { + return nil, kernel.BadAuthRequest("参考素材不存在、未就绪或类型不匹配") + } + refs = append(refs, TaskReference{ResourceID: resource.ID, Kind: resource.Kind, Role: role, SlotKey: strings.TrimSpace(ref.SlotKey)}) + } + if tool != nil { + capability := map[string]any{} + if err := json.Unmarshal([]byte(tool.CapabilityJSON), &capability); err != nil { + return nil, errors.New("工具能力配置无效") + } + if err := validateTaskCapabilities(tool.Kind, capability, refs); err != nil { + return nil, err + } + } + metadata := map[string]any{"source": "workbench", "workbenchKind": req.Kind} + workbenchSnapshot := map[string]any{"kind": req.Kind, "parameters": parameters} + snapshotReferences := make([]map[string]any, 0, len(refs)) + for _, ref := range refs { + reference := map[string]any{"resourceId": ref.ResourceID, "kind": ref.Kind} + if ref.Role != "" { + reference["role"] = ref.Role + } + if ref.SlotKey != "" { + reference["slotKey"] = ref.SlotKey + } + snapshotReferences = append(snapshotReferences, reference) + } + workbenchSnapshot["references"] = snapshotReferences + if tool != nil { + metadata["workbenchToolId"], metadata["workbenchToolName"], metadata["workbenchToolVersion"] = tool.ID, tool.Name, tool.Version + workbenchSnapshot["toolId"], workbenchSnapshot["toolName"], workbenchSnapshot["toolVersion"] = tool.ID, tool.Name, tool.Version + } + metadata["workbench"] = workbenchSnapshot + input := map[string]any{"mode": req.Kind, "prompt": strings.TrimSpace(req.Prompt), "config": providerConfig, "metadata": metadata} + if len(refs) > 0 { + imageRefs, videoRefs, audioRefs := []map[string]any{}, []map[string]any{}, []map[string]any{} + for _, ref := range refs { + value := map[string]any{"resourceId": ref.ResourceID, "storageKey": "resource:" + ref.ResourceID} + if ref.Role != "" { + value["role"] = ref.Role + } + if ref.SlotKey != "" { + value["slotKey"] = ref.SlotKey + } + switch ref.Kind { + case "image": + imageRefs = append(imageRefs, value) + case "video": + videoRefs = append(videoRefs, value) + case "audio": + audioRefs = append(audioRefs, value) + } + } + if len(imageRefs) > 0 { + input["referenceImages"] = imageRefs + } + if len(videoRefs) > 0 { + input["referenceVideos"] = videoRefs + } + if len(audioRefs) > 0 { + input["referenceAudios"] = audioRefs + } + } + if preset != nil { + meta := input["metadata"].(map[string]any) + meta["workbenchPresetId"], meta["workbenchPresetVersion"], meta["workbenchPresetName"] = preset.ID, preset.Version, preset.Name + meta["workbenchPresetPrompt"] = preset.PromptTemplate + workbenchSnapshot := meta["workbench"].(map[string]any) + workbenchSnapshot["presetId"], workbenchSnapshot["presetName"], workbenchSnapshot["presetVersion"] = preset.ID, preset.Name, preset.Version + workbenchSnapshot["presetPrompt"] = preset.PromptTemplate + } + return &TaskResolution{TaskType: map[string]string{KindImage: "canvas_image", KindVideo: "canvas_video"}[req.Kind], Operation: map[string]string{KindImage: "image", KindVideo: "video"}[req.Kind], Input: input, Parameters: parameters, Prompt: strings.TrimSpace(req.Prompt), Model: modelName, LogicalModelID: logicalModelID, ChannelID: channelID, Tool: tool, Preset: preset}, nil +} + +func normalizeTaskParameters(kind string, params map[string]any) (map[string]any, error) { + allowed := map[string]map[string]bool{ + KindImage: {"size": true, "quality": true, "count": true}, + KindVideo: {"seconds": true, "ratio": true, "resolution": true, "generateAudio": true, "watermark": true}, + }[kind] + if allowed == nil { + return nil, kernel.BadAuthRequest("工作台类型无效") + } + normalized := make(map[string]any, len(params)) + for key, value := range params { + if !allowed[key] { + return nil, kernel.BadAuthRequest("生成参数包含不支持字段: " + key) + } + switch key { + case "size", "quality", "ratio", "resolution": + text, ok := value.(string) + text = strings.TrimSpace(text) + if !ok || len(text) > 64 || strings.Contains(text, "://") || strings.HasPrefix(strings.ToLower(text), "data:") { + return nil, kernel.BadAuthRequest("生成参数格式无效: " + key) + } + if text != "" { + normalized[key] = text + } + case "count", "seconds": + number, ok := taskParameterInteger(value) + max := 20 + if key == "seconds" { + max = 3600 + } + if !ok || number < 1 || number > max { + return nil, kernel.BadAuthRequest("生成参数数值无效: " + key) + } + normalized[key] = number + case "generateAudio", "watermark": + flag, ok := value.(bool) + if !ok { + return nil, kernel.BadAuthRequest("生成参数格式无效: " + key) + } + normalized[key] = flag + } + } + return normalized, nil +} + +func taskParameterInteger(value any) (int, bool) { + switch number := value.(type) { + case int: + return number, true + case int64: + return int(number), int64(int(number)) == number + case float64: + if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number { + return 0, false + } + return int(number), float64(int(number)) == number + case json.Number: + parsed, err := strconv.Atoi(number.String()) + return parsed, err == nil + default: + return 0, false + } +} + +func validReferenceRole(kind, referenceKind, role string) bool { + if role == "" || role == "reference" { + return kind == KindVideo || referenceKind == "image" + } + return kind == KindVideo && referenceKind == "image" && (role == "first_frame" || role == "last_frame") +} + +func resolveModelInput(req TaskInput) (string, string, string, map[string]any, error) { + modelName := strings.TrimSpace(req.Model) + logicalModelID := strings.TrimSpace(req.LogicalModelID) + channelID := strings.TrimSpace(req.ChannelID) + if req.ModelChoice != nil { + choice := req.ModelChoice + if choice.UserChannelID != "" { + return "", "", "", nil, kernel.BadAuthRequest("工作台暂不接受浏览器自定义渠道选择") + } + if value := strings.TrimSpace(choice.LogicalModelID); value != "" { + if logicalModelID != "" && logicalModelID != value { + return "", "", "", nil, kernel.BadAuthRequest("模型选择字段不一致") + } + logicalModelID = value + } + if value := strings.TrimSpace(choice.ModelName); value != "" { + if modelName != "" && modelName != value { + return "", "", "", nil, kernel.BadAuthRequest("模型名称字段不一致") + } + modelName = value + } + } + if modelName == "" { + return "", "", "", nil, kernel.BadAuthRequest("必须选择一个模型") + } + // 创作页的系统目录模型多数只有渠道 ID + 模型名,没有 logicalModelId。 + // 两者都空才拒绝;渠道是否为系统渠道由后续 admission 再校验。 + if logicalModelID == "" && channelID == "" { + return "", "", "", nil, kernel.BadAuthRequest("工作台任务必须选择系统模型目录中的模型") + } + config := map[string]any{"mode": req.Kind, "model": modelName} + if channelID != "" { + config["channelId"] = channelID + } + return modelName, logicalModelID, channelID, config, nil +} + +func validReferenceKind(kind string) bool { + return kind == "image" || kind == "video" || kind == "audio" +} + +func containsSensitiveURL(value any) bool { + switch item := value.(type) { + case map[string]any: + for key, child := range item { + lower := strings.ToLower(key) + if lower == "api_key" || lower == "apikey" || lower == "authorization" || lower == "headers" || lower == "baseurl" || lower == "base_url" || lower == "url" { + return true + } + if containsSensitiveURL(child) { + return true + } + } + case []any: + for _, child := range item { + if containsSensitiveURL(child) { + return true + } + } + } + return false +} + +func validateTaskCapabilities(kind string, capability map[string]any, refs []TaskReference) error { + allowedRefs, _ := capability["referenceKinds"].([]any) + allowedSet := map[string]bool{} + for _, value := range allowedRefs { + if text, ok := value.(string); ok { + allowedSet[text] = true + } + } + var contract InputContract + if raw, ok := capability["inputContract"]; ok { + encoded, _ := json.Marshal(raw) + _ = json.Unmarshal(encoded, &contract) + } + slots := map[string]ReferenceSlot{} + for _, slot := range contract.Slots { + slots[slot.Key] = slot + } + counts := map[string]int{} + for _, ref := range refs { + if len(allowedSet) > 0 && !allowedSet[ref.Kind] { + return kernel.BadAuthRequest("当前工具不支持该类型参考素材") + } + if kind == KindImage && ref.Kind != "image" { + return kernel.BadAuthRequest("生图任务只允许使用图片参考素材") + } + if kind == KindVideo && ref.Kind != "image" && ref.Kind != "video" && ref.Kind != "audio" { + return kernel.BadAuthRequest("视频任务参考素材类型无效") + } + if len(contract.Slots) == 0 { + continue + } + slotKey := strings.TrimSpace(ref.SlotKey) + slot, ok := slots[slotKey] + if !ok { + return kernel.BadAuthRequest("参考素材未指定有效的输入槽位") + } + mediaAllowed := false + for _, mediaType := range slot.MediaTypes { + if mediaType == ref.Kind { + mediaAllowed = true + break + } + } + if !mediaAllowed { + return kernel.BadAuthRequest("参考素材类型与输入槽位不匹配") + } + counts[slotKey]++ + if counts[slotKey] > slot.MaxCount { + return kernel.BadAuthRequest("输入槽位的参考素材数量超出上限") + } + } + for _, slot := range contract.Slots { + if counts[slot.Key] < slot.MinCount || (slot.Required && counts[slot.Key] == 0) { + return kernel.BadAuthRequest("请补齐必需的输入素材:" + slot.Name) + } + } + return nil +} + +func toolRequiresPrompt(tool *model.WorkbenchTool) bool { + if tool == nil { + return true + } + config, err := parseCatalogConfig(tool.CapabilityJSON, tool.GuideJSON) + return err != nil || config.InputContract.PromptRequired || config.InputContract.MinTextReferences > 0 +} diff --git a/backend/internal/workbench/tasks_test.go b/backend/internal/workbench/tasks_test.go new file mode 100644 index 000000000..1139ee13a --- /dev/null +++ b/backend/internal/workbench/tasks_test.go @@ -0,0 +1,66 @@ +package workbench + +import ( + "testing" + + "yingce/backend/internal/model" +) + +func TestValidateTaskCapabilitiesInputContract(t *testing.T) { + capability := map[string]any{ + "referenceKinds": []any{"image", "video"}, + "inputContract": map[string]any{ + "promptRequired": false, + "slots": []any{ + map[string]any{ + "key": "subject", + "name": "Subject", + "mediaTypes": []any{"image"}, + "minCount": float64(1), + "maxCount": float64(2), + "required": true, + }, + }, + }, + } + + tests := []struct { + name string + refs []TaskReference + want bool + }{ + {name: "valid slot reference", refs: []TaskReference{{ResourceID: "image-1", Kind: "image", SlotKey: "subject"}}, want: true}, + {name: "missing required slot", want: false}, + {name: "unknown slot", refs: []TaskReference{{ResourceID: "image-1", Kind: "image", SlotKey: "unknown"}}, want: false}, + {name: "media type mismatch", refs: []TaskReference{{ResourceID: "video-1", Kind: "video", SlotKey: "subject"}}, want: false}, + {name: "slot count exceeded", refs: []TaskReference{{ResourceID: "image-1", Kind: "image", SlotKey: "subject"}, {ResourceID: "image-2", Kind: "image", SlotKey: "subject"}, {ResourceID: "image-3", Kind: "image", SlotKey: "subject"}}, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTaskCapabilities(KindVideo, capability, test.refs) + if (err == nil) != test.want { + t.Fatalf("validateTaskCapabilities() error = %v, want success = %t", err, test.want) + } + }) + } +} + +func TestValidateTaskCapabilitiesLegacyWithoutSlots(t *testing.T) { + capability := map[string]any{"referenceKinds": []any{"image"}} + refs := []TaskReference{{ResourceID: "image-1", Kind: "image"}} + if err := validateTaskCapabilities(KindImage, capability, refs); err != nil { + t.Fatalf("legacy reference without slot was rejected: %v", err) + } +} + +func TestToolRequiresPromptDefaultsAndOverride(t *testing.T) { + legacy := &model.WorkbenchTool{CapabilityJSON: `{}`} + if !toolRequiresPrompt(legacy) { + t.Fatal("legacy tool must keep requiring a prompt") + } + optionalPrompt := &model.WorkbenchTool{CapabilityJSON: `{"inputContract":{"promptRequired":false}}`} + if toolRequiresPrompt(optionalPrompt) { + t.Fatal("tool configured for optional prompts must allow an empty prompt") + } +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 3a4d59827..c471f632f 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -45,7 +45,9 @@ Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`ex `auth_sessions.reauthenticated_at` 记录当前会话的 Linux.do 重新核验时间,有效 5 分钟;`o_auth_states.reauth_user_id/reauth_session_id` 绑定发起重新核验的账号及会话。重新核验只允许原绑定的第三方身份,不创建账号或新登录会话。`auth_verifications.session_id/credential_hash` 将 `profile_email` 用途的验证码绑定到当前用户、当前会话和密码状态。密码、邮箱修改及旧会话撤销在同一事务提交;登录会话签发同时检查账号凭据,阻止并发旧密码登录在撤销之后再创建会话。升级执行 `migrate-schema up` / `verify`,禁止手工修改既有迁移记录。 -当前结构版本为 53;版本 53 新增独立登录日志表 `login_logs`,详见本页“用户登录日志”。版本 50 新增 `canvas_mutation_receipts`,按 `(user_id, canvas_id, mutation_id)` 唯一记录画布 operations 请求摘要、确定性结果和提交 revision,供响应丢失后的幂等重放使用;receipt 与画布内容、revision 在同一事务提交。版本 49 `banner_sort_order` 为 `banner_announcements` 增加可配置的 `sort_order` 展示顺序;版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 +当前结构版本为 54;版本 53 新增独立登录日志表 `login_logs`,详见本页“用户登录日志”。版本 50 新增 `canvas_mutation_receipts`,按 `(user_id, canvas_id, mutation_id)` 唯一记录画布 operations 请求摘要、确定性结果和提交 revision,供响应丢失后的幂等重放使用;receipt 与画布内容、revision 在同一事务提交。版本 49 `banner_sort_order` 为 `banner_announcements` 增加可配置的 `sort_order` 展示顺序;版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 + +版本 54 `workbench_tools_and_presets` 新增 `workbench_tools` 与 `workbench_presets`:工具记录生图/视频类型、分类、名称、说明、资源 ID、排序、能力/指南 JSON、启用状态、版本和操作人;预设记录工具外键、名称、说明、提示词模板、默认参数 JSON、排序、启用状态、版本和操作人。普通用户目录只返回启用工具与预设;管理写入均做管理员校验和审计。工具图标/封面必须引用管理员名下就绪图片资源,发布后的引用进入资源删除保护;用户目录通过服务端授权的短期 display 资源地址展示,不放宽资源所有者访问边界。工作台任务使用专属 DTO,由服务端确认工具/预设仍启用、参考资源归属与媒体类型,并从逻辑模型目录解析执行配置后复用现有任务 admission、计费和队列。`imageWorkbenchEnabled` 与 `videoWorkbenchEnabled` 位于 `feature_availability` JSON,默认关闭;分别保护目录读取和新任务提交。版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 42,回退需恢复匹配的数据库备份,并评估备份之后的新写入;不能只切回旧镜像。关闭自动迁移的生产环境必须运行正式迁移;手工改表不能更新 `schema_migrations` 的版本与校验和记录。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 68bf3edb4..885fe83d4 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -15,6 +15,12 @@ description: 当前项目已实现的核心工作台能力与使用入口 “账户与设置”新增“个人信息”,展示账号名称与绑定邮箱状态。用户可预览并保存个人头像或恢复系统默认头像,顶栏、侧栏、账户卡片与创作消息同步更新。密码修改与邮箱更换分别保存;修改密码需要当前密码,邮箱更换还需要新邮箱验证码,成功后退出所有登录会话。纯 Linux.do 账号可重新授权核验身份后设置本地密码或更换邮箱。沿用现有设置页、表单、按钮反馈与主题,未增加昵称、用户名或角色编辑。 +## 生图与视频工作台 + +普通用户可分别使用生图、视频工作台。通过工具卡片选择场景,套用预设提示词,按所选模型能力配置参数和参考素材;生成结果、运行任务及历史详情在同一页面查看。管理员在「工作台工具」维护分类、图标/封面、预设、使用说明与示例素材,并在「功能开放」分别控制两个入口。新入口默认关闭;工作台任务经过统一任务与计费服务创建。 + +详见[生图与视频工作台](./workbench)。 + ## 可美 KM Seedream 图片 `km-kemei-seedream` 声明式插件接入 Seedream 5.0 Pro / Flash 的文生图与参考图编辑,支持 URL / Base64 响应并由宿主统一保存图片。精确模型 ID 与扩展参数见插件包内 `docs/interface.md`。 diff --git a/docs/design/image-video-workbench-solution.md b/docs/design/image-video-workbench-solution.md new file mode 100644 index 000000000..f665aa06e --- /dev/null +++ b/docs/design/image-video-workbench-solution.md @@ -0,0 +1,246 @@ +# 生图与视频方案 + +## 1. 目标与范围 + +为普通用户提供两个独立入口: + +- **生图**:面向文生图、图生图和工具化的图像编辑任务。 +- **视频**:面向文生视频、图生视频,以及模型支持的视频/音频参考输入。 + +后台提供“工作台工具管理”,维护生图和视频工具目录、分类、预设提示词、图标/封面、使用说明及示例媒体;“功能开放”增加两个独立开关,由管理员控制对应用户入口和后端写入能力。 + +这不是新的模型协议实现。模型选择、能力判断、任务排队、计费、取消、媒体落库、资源访问与任务恢复均复用现有生成链路。工具目录负责组织场景、注入经过校验的预设内容和展示指南,不直接保存供应商密钥或任意上游请求配置。 + +## 2. 调研结论 + +### 外部页面 + +已访问: + +- https://maolong.da-ai.cc/toolbox/image-generation +- https://maolong.da-ai.cc/toolbox/video-generation + +页面可正常返回,但当前无登录会话的页面抓取只得到产品导航、页面标题和路由入口,生成区由前端/登录态驱动的内容没有暴露。因此可确认该产品将 AI 图片、AI 视频作为独立一级工作区;不能据此断言其具体参数布局、任务列表或交互细节。后续如需逐项视觉对标,应在可登录浏览器会话中再做实页观察。 + +### 仓库现状 + +- 路由由 `web/src/router.tsx` 管理;普通用户导航在 `workspace-sidebar-nav.tsx` 组合,并从用户会话读取 `FeatureAvailability`。 +- 已有 `/tasks` 创作历史,可显示图片/视频任务、状态、预览和详情;任务详情数据包含输入、输出、开始/完成时间及账单摘要。 +- `web/src/services/api/generation-task.ts` 的 `runBackendGenerationTask` / `submitBackendGenerationTask` 负责把前端配置、模型能力和参考媒体转换为统一任务请求。页面不应直接请求供应商。 +- 后端 `CreateTask` 会重新解析模型/渠道/路由、能力、用户配额与账单;`POST /api/tasks` 是标准模型任务入口。媒体必须先保存为资源,再以资源引用提交,不能在任务 JSON 中长期持有 data URL。 +- 当前 `FeatureAvailability` 使用系统设置持久化,管理员更新有审计;前端 `RequireFeature` 只适合路由层,开放状态不能仅靠隐藏导航实现。任务写接口需在后端创建工作台任务时做强校验。 +- 后台已有系统设置、管理页面、素材上传和提示词版本管理模式,可作为工具管理 UI 的相邻实现参考;但创作工具预设与现有面向系统提示策略的模板语义不同,不建议强行共用同一数据实体。 + +## 3. 信息架构与页面布局 + +### 导航和路由 + +- 一级入口名称固定为“生图”“视频”,建议路径分别为 `/toolbox/image`、`/toolbox/video`。 +- 两个入口分别绑定 `imageWorkbenchEnabled`、`videoWorkbenchEnabled`。未开放时侧栏不显示;直接访问路由显示统一的“暂未开放”状态并返回创作台。 +- 工具、分类和预设使用可分享的 URL 查询参数:`tool=`、`preset=`。切换工具时更新 URL,刷新和复制链接可恢复上下文;草稿提示词和本地临时文件不写 URL。 +- 任务历史可以直接作为右侧面板内的历史记录查看,不新增重复的全屏历史入口。现有 `/tasks` 保持全局创作历史职责。 + +### 桌面布局 + +在可用内容宽度足够时使用三列工作台,按用户给定比例作为基线而非固定像素:左侧约 40%,中间约 30%,右侧约 30%。三列之间用细边界和间距分区,避免卡片套卡片。建议主区最小可用宽度约 1200px;列宽使用 `minmax()` 并为右栏任务行预留稳定宽度。 + +**左侧:工具与生成输入** + +1. 顶部工具选择器显示当前工具图标、名称、副标题、下拉入口及“新建/切换工具”操作。 +2. 打开选择器后使用用户提供的参考图方向:标题/搜索/分类标签/工具卡片网格。卡片包括图标或缩略图、名称、简短用途和选中状态;分类可搜索,键盘可浏览及选中。没有搜索结果时提供清除搜索入口。 +3. 选中工具后,工具配置表单按工具支持项显示模型、生成参数、提示词、参考图/视频/音频。未被模型能力支持的项隐藏或解释为不可用,不能显示无效控件。 +4. 工具预设以可发现的弹窗/Popover 选择,点选后把模板内容填入提示词草稿;如果模板定义了参数默认值,只填充未被用户手动修改的字段。不能静默覆盖用户输入。 +5. 提示词支持字数提示、清空、必要的优化入口和生成前校验。参考媒体支持预览、删除、排序、模型数量/类型限制、上传进度和失败重试。界面明确区分“已保存资源”与“尚在上传”。 +6. 生成按钮显示当前模型/配置下可获得的预估积分;报价不可用时不猜测价格,提交后按服务端结果显示实际账单。 + +**中间:生成结果与工具说明** + +上下两区各约占中间栏可视高度的一半: + +- 上区是当前选中任务结果。未提交显示空态;排队/生成中显示带进度语义的骨架和低强度流动高光;成功后显示图片画廊或视频播放器;失败显示原因、重试/查看任务操作。动效尊重 `prefers-reduced-motion`,不影响页面滚动或结果尺寸。 +- 多结果图片以稳定网格/轮播浏览,图片保持原比例并可放大、下载、保存到资产。视频结果显示封面、时长、播放控件和下载/保存操作。 +- 下区展示当前工具的说明、适用模型/模式、参数指南、注意事项和示例图/视频。说明来自后台受控 Markdown/结构化内容;禁止直接渲染未经清理的 HTML。 +- 没有工具或说明加载失败时分别显示选择工具提示/可重试错误,不用空白区占位。 + +**右侧:运行中任务与历史** + +- 上半区为正在排队/执行的工作台任务,按最近更新排序,显示缩略图、工具/模型、提示词截断、进度/阶段、已耗时及取消入口(仅在后端允许时)。状态来自后端任务服务,离开页面后仍可恢复。 +- 下半区为历史生成记录,支持图片/视频过滤、时间倒序及增量分页。条目显示缩略图、成功/失败/取消状态、工具名、模型、创建时间。 +- 点击一条历史打开详情弹窗:左侧显示图片/视频结果,右侧显示完整提示词、工具与模型、生成配置、参考素材、耗时、积分账单和状态/错误。详情以任务记录为准;不可访问或过期的媒体显示明确状态,不暴露上游 URL 或内部错误字段。 +- 右侧两区各自滚动,桌面下不跟随左侧参数表单滚动;内容区边界清晰,列表项使用紧凑行,不以重复的大型装饰卡片占据空间。 + +### 响应式退化 + +- ≥1440px:采用完整三栏,按 40/30/30 比例动态分配。 +- 1024–1439px:左侧输入区与中间结果区并列,右侧任务/历史收为可切换抽屉或窄栏,默认展示输入和结果。 +- <1024px:单列工作区,顶部以“生成 / 结果 / 任务”Tab 或分段控件切换;生成表单不能被常驻任务栏挤压。弹窗变为全屏或底部抽屉。 +- 不允许在窄屏强行保持三栏导致横向滚动。拖放上传在触屏场景提供等价文件选择操作。 + +## 4. 用户核心流程 + +1. 用户从导航进入图片或视频;前端确认功能开放状态。 +2. 用户通过工具选择弹窗选定场景工具,页面加载该工具可用预设和说明。 +3. 用户选择当前会话模型;前端根据模型目录能力显示对应参数和参考媒体类型,不支持的组合及时阻止提交。 +4. 用户选用预设提示词并修改,上传参考媒体到资源服务,检查报价/配额提示后提交。 +5. 前端调用现有统一任务服务创建任务;任务 ID 纳入页面状态。右栏立即出现任务,中间显示处理中状态。 +6. 订阅/轮询任务状态。成功后中间展示结果并更新右栏历史;失败、取消、过期或结果回存失败均提供相应恢复路径。 +7. 用户点击历史条目,在详情弹窗检查结果、参数、耗时和服务端账单。 + +重试必须创建新的尝试并保留原任务关联,不在客户端伪造任务状态;取消以服务端返回为准。刷新页面后根据后端任务重新构建运行中任务列表。 + +## 5. 功能模块边界 + +### 前端 + +- 新增两个工作台路由页面和共享工作台框架,工具选择器、配置表单、结果区、任务/历史区作为页面私有组件起步;只有图片与视频确实共享交互合同后,再抽成跨页面组件。 +- 模型参数和媒体能力统一由已有模型 capability/profile 数据驱动,避免工具配置复制一份参数协议。 +- 页面负责协调选择、表单草稿和展示;API service 负责工具目录读写、上传引用和任务调用;纯映射/校验留在 `lib`。 +- 用户端工具目录响应不包含后台草稿、隐藏工具、内部存储地址或管理员审计内容。 + +### 后端工具管理 + +建议在管理后台新增“工作台工具”运营入口,包含工具列表、预览、创建/编辑、排序、上线/下线、版本历史;左右分区按“生图工具 / 视频工具”筛选。 + +工具清单建议字段: + +- `id`:稳定 ID,不以展示名称作为外键。 +- `kind`:`image` 或 `video`。 +- `categoryId`、`name`、`subtitle`、`description`。 +- `iconResourceId` / `coverResourceId`:引用平台资源,不保存任意 URL。 +- `sortOrder`、`enabled`、`createdAt`、`updatedAt`、`updatedBy`、`version`。 +- `supportedModes` / `capabilityRequirements`:受限的能力声明,表达文生图、图生图、图生视频、首尾帧、音频参考等 UI 需求;不能用于指定协议 URL。 +- 可选展示指南/示例媒体资源引用。 + +预设提示词建议独立实体,以便版本和启用状态独立维护:`id`、`toolId`、`name`、`summary`、`promptTemplate`、受限的 `defaultParameters`、`sortOrder`、`enabled`、审计字段。模板变量使用显式白名单和 schema 校验,不支持服务端代码执行或任意插值函数。删除被任务历史引用的工具/预设时采用下线/软删除,历史记录仍显示任务创建时保存的工具快照名称。 + +分类是导航元数据,可以做独立表或受校验的配置项;首版建议分类资源和排序统一由后台管理,内置系统分类 ID 保持稳定。工具预设中的默认参数仅可填入普通生成参数,不能覆盖用户模型凭证、渠道、资源归属、计费值、任务类型或服务端路由字段。 + +### 图片/视频资源 + +图标、工具封面和示例媒体走现有资源上传/访问能力,后台只保存资源 ID。按资源归属检查管理员上传、引用和删除;被工具引用的媒体不可直接物理删除。用户生成结果使用现有任务结果及资源访问接口,避免复制二进制到新表。 + +## 6. 功能开放与权限/安全 + +### 开放控制 + +- 在 `FeatureAvailability` 增加 `imageWorkbenchEnabled` 与 `videoWorkbenchEnabled`,并沿用 `/admin/settings/features` 的管理员即时变更和审计约定。 +- 公共登录后会话或 `/features` 返回两个字段;旧配置缺字段按明确的安全默认值补齐。建议新能力初始关闭,避免代码部署后用户入口意外曝光;该默认值需与上线策略一并确认。 +- 前端侧栏和路由读取该字段,用户直接打开关闭的页面时显示未开放状态。 +- 后端对工作台目录的普通用户读取、任务创建入口分别检查对应开关。至少对 `POST /tasks` 增加按任务 `metadata.source`/规范化模式识别工作台来源并校验,但不可只信客户端传入的 `source`;必须同时由后端根据任务媒体模式和工作台专属入口/签名字段建立可验证边界。 +- 更推荐新增受开关保护的工作台任务创建 API,由服务端注入 `workbenchKind`、工具 ID 和预设 ID,再调用现有 `CreateTask` admission 服务;不要依赖通用 `/tasks` 的自由客户端 metadata 来区分是否属于工作台。内部 Agent、画布及短剧流程继续使用原任务入口,不因关闭新工作台而误停。 +- 关闭开关只阻止新目录访问和新任务创建,不应丢失或隐藏用户已有历史任务、资源、账单及正在执行任务。是否阻止关闭后访问工作台历史需保持与现有任务中心政策一致。 + +### 数据与执行安全 + +- 工具/预设管理接口必须管理员授权;用户查询只返回启用版本。更新、下线和排序均审计操作人、前后值或版本号。 +- 所有查询和写入按当前用户、资源归属及任务归属检查;任何详情 API 不允许跨用户读取。 +- 请求体只提交资源 ID、表单参数和提示词,不含第三方 API Key、Cookie、远端媒体 URL或 inline data URL。参考素材先进入资源服务,再由服务端转为受控 Provider 输入。 +- 后端基于所选模型能力重新校验图像/视频/音频参考数量、类型、尺寸、时长、比例、分辨率和工具要求;前端校验仅改善反馈。 +- 计费、配额、并发限制及积分预占/退款由现有任务与账单服务负责。管理工具表不保存积分价格,详情账单直接取服务端任务/订单摘要。 +- 提示词作为用户输入按现有安全策略处理,不执行模板内容;说明 Markdown 输出需经过白名单渲染和链接协议过滤。 + +## 7. API 草案 + +以现有 `/api` 风格为准,路径最终以 OpenAPI 更新为准: + +**用户端** + +- `GET /api/workbench/tools?kind=image|video`:返回启用工具、分类、启用预设、资源预览引用与说明。 +- `GET /api/workbench/tools/:toolId`:返回单个工具的公开详情。 +- `POST /api/workbench/tasks`:携带 `toolId`、`presetId?`、`kind`、用户提示词、模型选择意图、生成参数及资源 ID;服务端加载工具/预设快照、校验开关/模型能力后转入标准任务 admission 与账单流程。 +- `GET /api/workbench/tasks?kind=&activeOnly=&cursor=`:可选的工作台筛选任务查询;若现有 `/tasks` 查询可通过可信的服务端元数据过滤并且分页性能合格,则可以复用而不增加重复 API。 +- 历史详情优先复用 `GET /api/tasks/:id` 及资源访问 API,保证同一归属校验。 + +**管理端** + +- `GET /api/admin/workbench/tools?kind=` +- `POST /api/admin/workbench/tools` +- `PATCH /api/admin/workbench/tools/:id` +- `POST /api/admin/workbench/tools/:id/publish` / `unpublish` +- `POST /api/admin/workbench/tools/:id/presets` +- `PATCH /api/admin/workbench/presets/:id` +- `POST /api/admin/workbench/presets/:id/activate` / `unpublish` +- 资源继续走现有上传/资源访问接口。 + +管理端写接口应采用明确 DTO 和字段白名单,不能直接反序列化到数据库实体;启用或发布动作校验必填内容、资源引用、模板 schema 和分类归属。 + +## 8. 数据存储和迁移建议 + +- 建议新增 `workbench_tools`、`workbench_tool_presets` 两张表;工具与预设使用稳定 ID、状态和排序字段,工具删除采用软删除或下线。预设可支持版本表,首版如要缩小范围可采用更新时新增版本记录、始终只有一个 active 版本。 +- 若说明与示例内容较小且必须随工具原子发布,可放工具 JSON 文档字段;资源本身仍只保存资源 ID。不要把图片二进制或短期 CDN 签名地址写入数据库。 +- 任务创建时在受控任务元数据中记录工具 ID、工具名称/版本、预设 ID/版本、工作台类型和展示所需配置快照,保证工具下线、重命名后历史详情仍可解释。快照不能覆盖 `Task.InputJSON` 中现有凭证保护/敏感信息规则。 +- 数据表变更需更新 `docs/content/docs/backend/backend-database.mdx`。API 变化同步 OpenAPI 与 HTTP API 专题文档。 + +## 9. 后台管理交互 + +工具列表优先,顶部提供“生图 / 视频”过滤、搜索、状态筛选和新建入口。行内展示图标、名称、分类、预设数量、状态、排序和更新时间;常用的预览/编辑/上下线操作直接可见,删除作为谨慎操作并在有引用时禁用或提示下线代替。 + +工具编辑采用全屏抽屉或较宽编辑台,分为:基础信息、图标/封面、能力需求与 UI 字段、使用说明/示例、预设提示词。右侧或预览模式展示用户端工具卡与说明区预览。预设编辑器提供模板文本、变量白名单、默认参数 JSON schema 表单化编辑、启用状态及版本说明。发布前校验名称、分类、图像资源、提示词长度、默认参数和说明内容;保存成功后用户端目录按版本更新。 + +“功能开放”增加两项开关,并明确影响:关闭后隐藏入口并禁止新工作台任务;已有任务记录保留。工具本身的上线状态与全局功能开放是两层状态,用户端有效状态为两者皆开放。 + +## 10. 一次性交付范围 + +本功能按完整闭环一次性交付,不拆成先做生图、后补视频或先上工具目录、后补后台的阶段。交付范围同时包含用户端两个工作台、后台工具管理、功能开放控制、后端目录/任务接口、数据迁移、权限审计、文档和验收。 + +上线前必须同时具备可配置的生图与视频工具、预设、说明和示例素材;两个用户入口及对应服务端开关;模型能力驱动的参数表单;资源上传;异步任务恢复;任务取消/重试;结果预览;右侧运行中任务与历史;详情中的提示词、配置、耗时和实际积分;完整管理端编辑/预览/上下线能力。若任何一个闭环部分未完成,不视为该功能已交付。 + +建议以一个完整功能分支和统一发布窗口落地;数据迁移保持向前兼容,首次发布默认关闭两个新入口,完成线上工具配置和冒烟后由管理员开启。可以在开发过程中按模块并行实现,但这只是工程组织方式,不改变“一次完整交付”的产品验收边界。 + +## 11. UI 交互与视觉设计 + +### 工具选择弹窗(参考图 1) + +- 当前工具以一行紧凑的图标/缩略图、名称、副说明和下拉箭头呈现;右侧“新建”是清晰命令按钮。点击整行或箭头打开工具选择弹窗,弹窗从当前工作台上下文打开并保留滚动位置。 +- 弹窗顶部使用“你想创作什么?”作为标题,旁边一行短说明;右侧提供“清除选择”。下方是“推荐 / 人物 / 商品 / 场景 / 增强”等后台可配置分类标签,分类行末尾放搜索输入框。小屏时搜索移到分类下方,不能挤压标签。 +- 工具以两列图文卡片展示,每张卡片固定最小高度并采用左侧方形缩略图、右侧名称和最多两行简介。选中卡片使用清晰的深色边缘与右上角勾选标记,不选中仅用柔和表面差异和轻微悬停抬升表达;状态不能只靠颜色区分。 +- 点卡片立即选中并关闭弹窗,切换工具前若提示词或参考素材有未提交修改,不清空内容:同类别工具保留输入草稿;不兼容字段保留在草稿中但暂不提交,回切工具时恢复。只有用户明确选择“新建/清空”才重置。 +- 弹窗支持搜索工具名、简介和分类;搜索与分类可组合。点击清除选择后回到工具未选状态,但不清除生成草稿。Escape 关闭且焦点返回触发按钮;方向键和 Tab 可遍历卡片,Enter/Space 选择。弹窗内滚动,不滚动背景页面。 +- “更多技能持续上线中”作为可选的目录尾部说明,不伪装成禁用工具卡;后台没有配置更多项时不显示。 + +### 生成输入页(参考图 2) + +- 顶部保留工具摘要与“新建”命令。生图页面中央以宽阔、低干扰的提示词编辑区为主,而不是把输入项切成许多小卡片:左上显示已添加参考图数量与上限,右上放上传/排列视图/展开等图标按钮;中间提供上传占位缩略图和提示词编辑区;底部显示字符数与语音输入入口(若当前产品语音能力可复用)。 +- 参考素材槽采用轻量缩略图,未上传时展示线稿占位与“添加图片/视频/音频”入口;上传后可排序、替换、删除和放大预览。每个素材显式显示上传状态、类型和失败重试,不以头像或素材缩略图暗示上传成功。 +- 提示词区域内提供预设入口,选择预设后把文本插入光标处或替换空草稿;若草稿已有内容,弹出轻量选项“替换 / 追加 / 返回”,不直接覆盖。模板变量由用户填写或保留可读占位符,未完成必填变量时生成按钮禁用并定位到变量。 +- 提示词下方把“模型”和“生成设置”做成并排的紧凑摘要选择器。点击模型项打开模型选择层,搜索、来源/能力标签与不可用原因可查;点击生成设置打开按当前模型能力生成的参数面板。图片示例中的“自适应 · 1K 高 · PNG”是设置摘要,不应在不同模型下固定显示这些选项。 +- 视频沿用同一输入骨架,但根据能力将素材槽扩展为首帧、尾帧、角色/场景参考、视频参考和音频参考,并把时长、画幅、分辨率、音频等配置放在生成设置层。首尾帧与普通参考媒体使用不同槽位和类型标记。 +- 底部主按钮横向填满输入列,按钮主文案明确媒体类型(“立即生成图片”/“立即生成视频”),旁边显示“预估 X 积分”或“预计 -- 积分”。正在上传、必填校验失败、模型不可用、配额不足时明确说明阻塞原因;提交后按钮显示排队/生成状态,但允许用户继续编辑下一条草稿(除非当前模型/流程限制同一输入)。 +- 长提示词时输入区自然扩展或内部滚动,底部模型/设置选择器和生成按钮保持可见;键盘快捷提交需避开 IME composition,Enter 不应意外发送多行提示词。 + +### 视觉语言与 Aceternity 使用 + +- 以参考截图的浅色中性表面和柔和边界为基准,减少连续硬描边:大区块通过相邻表面、留白、浅层阴影和标题层级区分;仅选中、键盘焦点、分隔和危险状态使用更明确的边界。避免每个字段都包装成卡片,也避免卡片套卡片。 +- 保持三栏工作台本身克制、适合长时间创作;颜色来自现有语义 token,不大面积铺单一紫蓝渐变或装饰光斑。工具封面、生成结果和真实示例承担主要视觉色彩。 +- 可复用 Aceternity `SpotlightSurface` 作为工具选择卡片/结果区域的轻量悬停光照,使用低对比 spotlight 并尊重减少动态偏好;`HoverBorderGradient` 仅用于主要生成按钮或确有焦点引导价值的单一主操作,不用于所有卡片边框;`CometCard` 可用于工具封面卡的轻微悬停视差,但只在指针设备开启,触屏和减少动态时静态呈现。生成中骨架的流动高光优先采用本地 CSS 动画和语义状态,不为此叠加重型背景组件。 +- 不在工作区主背景使用 `BackgroundBeams`、Meteors 或无语义粒子;生成面板不能因装饰动效影响媒体可辨识度、对比度、可读性和性能。所有动效支持 `prefers-reduced-motion`,键盘焦点始终清晰。 +- Aceternity 组件以现有仓库组件实现为准,先核对组件包体积、SSR/懒加载和 reduced-motion 行为;缺少必要行为时优先扩展已有包装,不为视觉效果新增重复动画库。 + +## 12. 验收标准 + +- 两个开放开关分别控制各自导航、用户目录和后端新建任务;关闭其中一项不影响另一项,也不影响画布、短剧或 Agent 的既有生成。 +- 管理员可创建、修改、排序、发布和下线两类工具及预设;未启用内容对普通用户不可见,历史快照仍可读。 +- 工具卡片包含名称、说明、图标/封面、分类、搜索和明确选中状态;键盘操作可完成搜索、选择和关闭弹窗。 +- 图片与视频参数根据真实模型能力展示;服务端拒绝不匹配参数、越权资源和已下线工具请求。 +- 任务创建后中间区立即呈现占位,右栏任务可持续更新;刷新后仍能看到运行任务。任务结束后可展示媒体、状态和真实账单;所有失败路径有可恢复反馈。 +- 任务详情展示服务端记录的提示词、参数、工具/预设版本、模型、耗时和积分;媒体访问始终经过资源授权机制。 +- 不把密钥、Cookie、inline media data、内部上游地址或短期签名地址写入工具目录、任务快照、URL 或日志。 +- 三栏在宽屏无重叠;中等宽度和移动端退化为有序单列/标签切换,无页面级横向溢出;减少动态效果时骨架动画关闭或静态化。 + +## 13. 待决策项 + +1. 新增功能开关的默认值是否初始关闭,建议默认关闭,待首批工具和权限路径完整后由管理员开放。 +2. 模型来源范围:首版只允许后台配置的系统/逻辑模型,还是也允许用户自己的模型配置。当前任务链路已能承接多种模型来源,但开放范围会影响报价、配置校验、用户体验和支持成本。 +3. 用户提交提示词是否允许为空(例如纯参考图编辑),按具体模型能力决定,建议由 operation/profile 声明而不是工作台写死。 +4. 预设提示词是否需要版本历史与灰度发布;建议保留版本号/历史快照,首版可不做复杂灰度。 +5. 右侧历史是否只筛选“工作台任务”,以及 `/tasks` 是否仍作为全局任务历史。建议两者并存,工作台只展示由工作台创建的任务。 +6. 用户提供参考图展示了 6 个工具卡和“更多技能持续上线中”,实际首发的工具目录、图标与示例媒体需产品侧提供或由运营配置。 + +## 14. 参考与代码依据 + +- 外部参考:https://maolong.da-ai.cc/toolbox/image-generation、https://maolong.da-ai.cc/toolbox/video-generation。当前抓取只确认独立图片/视频入口,主体 UI 未能读取。 +- 用户提供的工具选择截图:工具选择弹窗、分类/搜索、图文卡片与选中反馈作为直接视觉参考。 +- 路由与导航:`web/src/router.tsx`、`web/src/components/layout/workspace-sidebar-nav.tsx`、`web/src/stores/use-user-store.ts`。 +- 功能开放:`backend/internal/platform/feature_availability.go`、`backend/internal/handler/feature_availability.go`、`web/src/pages/admin/components/feature-availability-panel.tsx`。 +- 生成与任务:`web/src/services/api/generation-task.ts`、`web/src/services/api/task-center.ts`、`backend/internal/handler/routes.go`、`backend/internal/app/task_creation.go`、`backend/internal/model/models_task.go`。 +- 管理端参考:`web/src/pages/admin/storyboard-prompts/storyboard-prompts-page.tsx` 及现有资源上传/访问服务。 diff --git a/scripts/workbench-catalog-seed.ts b/scripts/workbench-catalog-seed.ts new file mode 100644 index 000000000..14a7bb319 --- /dev/null +++ b/scripts/workbench-catalog-seed.ts @@ -0,0 +1,265 @@ +const imageRefs = { + product: "0445ed7bced1a61fdaa4f73d5c53d1f6", + fashion: "b3f1c822978bcf6f5d0bc0b3e1a64833", + beauty: "638f9ce1f53fc4032378cf0ef5bb8d92", + bag: "a996ec9314ef33d40ec1106e471426b7", + coffee: "02960243dac877ed674c67c73a2d8626", + food: "faaedd952b3fb5f5141b57fbe18c02af", + tech: "ea777fa92a86a9ecd2b799e182717d86", + man: "ef87854ec79c91ed743193983e186604", + elder: "c3525b35c1a87bbbe4f4301b26ef096e", + city: "bc835fd14e7418c6e2c9f7c9f2d3da54", + interior: "e5727d30e239f25cc01dcdb9b275c847", + forest: "dd1b57b5a50cc97d9a4aba408af4cc97", + film: "0f64216d407eb6e19788e4c6ccfd1e18", +}; + +const sources = { + ecommerce: [ + "https://github.com/dlazy-ai/ecommerce-skills", + "https://github.com/xianyu110/ecommerce-image-skills", + "https://www.reddit.com/r/comfyui/comments/1u783i6/built_a_product_catalog_photo_generator_with_3/", + "https://www.reddit.com/r/comfyui/comments/1o2a3yu/looking_for_a_workflow_to_create_product/", + ], + drama: [ + "https://github.com/zenstory-ai/drama-skills", + "https://github.com/xixihhhh/ai-short-drama-skill", + "https://github.com/openlark/skills/tree/main/skills/storyboard-prompt-generator", + "https://github.com/agentara/skills/tree/main/skills/aigc/video-storyboard", + ], + video: [ + "https://help.runwayml.com/hc/en-us/articles/39789879462419-Gen-4-Video-Prompting-Guide", + "https://help.runwayml.com/hc/en-us/articles/42460036199443-Text-to-Video-Prompting-Guide", + "https://help.runwayml.com/hc/en-us/articles/35694045317139-Gen-4-Image-Prompting-Guide", + "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4", + ], +}; + +// kind|category|key|name|focus|input 1|input 2|sample image +const rows = ` +image|product|product-white-hero|商品白底主图|把商品置于纯白无缝背景中央,完整呈现轮廓、真实颜色、材质和包装细节,使用柔和棚拍光与克制自然投影|商品原图||product +image|ecommerce|product-soft-shadow|商品柔影主图|清理背景并重建柔和接触阴影,不改变商品外观、比例、标签与配件|商品原图||product +image|ecommerce|product-angle-set|商品多角度套图|依据同一商品参考生成正面、侧面、背面和斜侧角度,结构、颜色、纹理及标识保持一致|商品原图|细节参考图|tech +image|ecommerce|product-macro-detail|商品细节微距|突出材质纹理、做工、接口或关键部位的微距商业摄影,保留真实边缘与比例|商品原图|细节参考图|bag +image|ecommerce|fashion-on-model|服装平铺上身|将平铺服装自然穿到模特身上,衣型、印花、缝线、纽扣和颜色逐项忠实保留|服装平铺图|模特姿势参考|fashion +image|ecommerce|fashion-lookbook|服装上身套图|为同一套服装生成正面、侧面、背面和行走姿态,保持模特身份、衣着与摄影光线一致|服装图|模特参考图|fashion +image|ecommerce|fashion-colorway|服装颜色变体|只替换服装主色或指定配色,锁定版型、面料纹理、模特、姿势、背景和裁切|服装原图||fashion +image|ecommerce|shoe-on-model|鞋履上脚图|将鞋款自然穿在模特脚上,鞋型、鞋底纹路、鞋带、配色与左右脚细节保持一致|鞋商品图|模特姿势参考|product +image|ecommerce|bag-scale-lifestyle|包袋手持比例场景|展示包袋真实尺寸与肩背/手提关系,保留包型、五金、缝线和品牌细节|包袋原图|模特参考图|bag +image|ecommerce|jewelry-wear|饰品佩戴图|把饰品自然佩戴于人物身上,准确保持镶嵌、链节、比例和金属色,不增加宝石|饰品原图|佩戴部位参考|fashion +image|ecommerce|skincare-packshot|护肤品棚拍主图|为瓶罐构建干净专业的美妆棚拍,标签文字与包装结构必须按参考保留,不臆造功效|护肤品原图||beauty +image|ecommerce|skincare-texture|护肤质地细节图|以凝露、乳霜或精华质地为视觉重点,瓶身标签保持清晰且不生成未经证实的成分或功效|护肤品原图|质地参考图|beauty +image|ecommerce|cosmetic-swatch|彩妆色号展示|展示指定色号在皮肤/色卡上的真实观感,维持颜色准确,不添加未提供的色号或宣传文案|彩妆商品图|色号参考图|beauty +image|ecommerce|perfume-still-life|香水氛围静物|以玻璃反射、液体色泽和高级布光构建香水静物广告,瓶体与标签保持原样|香水瓶原图|品牌氛围参考|beauty +image|ecommerce|coffee-packaging|咖啡包装场景图|表现咖啡包装、豆粒和冲煮情境,袋型、标签、净含量与印刷位置严格匹配原图|咖啡商品图|场景参考图|coffee +image|ecommerce|food-menu-hero|食品菜单主图|将菜品拍成自然可食用的菜单主视觉,准确呈现份量、配料和器皿,不凭空增加食材|食品参考图|器皿参考图|food +image|ecommerce|drink-cold-scene|饮料冰感场景图|呈现杯身、饮料颜色、冰块与冷凝水的清爽广告静物,不改变包装或液体实际颜色|饮料商品图|背景参考图|food +image|ecommerce|furniture-room|家具空间主图|将家具自然放入尺度可信的居家空间,家具结构、尺寸比例、颜色和面料保持参考一致|家具商品图|室内空间参考|interior +image|ecommerce|home-decor-vignette|家居软装搭配图|用合理比例与克制道具呈现家居用品使用情境,商品是主角,避免遮挡商品关键结构|家居商品图|空间风格参考|interior +image|ecommerce|electronics-packshot|数码商品主图|突出设备机身、接口、按键和真实材质,屏幕保持干净且不伪造界面或品牌文字|数码商品图||tech +image|ecommerce|appliance-in-use|小家电使用场景|展示小家电真实使用姿态与合理空间关系,机身比例、按钮和线缆结构保持准确|家电商品图|使用场景参考|tech +image|ecommerce|packaging-lineup|包装套系陈列|将同一品牌的不同包装有序陈列,统一光线、机位与比例,逐一保留每个 SKU 标签|多款包装图||beauty +image|ecommerce|listing-benefit-layout|电商详情卖点版式|围绕已确认卖点规划主体、局部特写和留白区域,文字区只留干净空白供后期排版,不生成乱码|商品图|品牌风格参考|product +image|ecommerce|vertical-product-page|竖版商品详情视觉|按移动端竖屏阅读顺序安排主视觉、材质细节和场景图,商品外观一致且保留安全留白|商品图|详情页参考|beauty +image|ecommerce|campaign-key-visual|电商活动主视觉|为活动海报生成商品主视觉与可编辑留白,使用指定色彩层级,不生成促销数字、价格或伪文字|商品图|品牌色参考|bag +image|short-video|ugc-cover-product|商品 UGC 封面图|构建可信的手持开箱/使用封面,商品清晰可辨,人物动作自然,预留后期标题区|商品图|人物/手势参考|coffee +image|short-video|short-ad-cover|竖屏短广告封面|围绕一个明确商品卖点做强主体竖版封面,主体位置适配平台裁切,留白给后期标题|商品图|视觉风格参考|product +image|ecommerce|campaign-ab-variants|商品广告 A/B 变体|在保持 SKU 和品牌不变的前提下,生成两种不同构图/背景方向用于创意测试,不虚构产品利益点|商品图|品牌参考图|bag +image|product|product-background-replace|商品换背景|只替换环境背景与地面光影,商品形状、颜色、标签、透视及附件像素级忠实|商品原图|目标背景参考|product +image|ecommerce|fabric-detail-board|面料与工艺细节板|用近景展示织法、面料厚度、接缝与纽扣,细节必须来自参考图,不补造隐藏工艺|服装原图|工艺细节参考|fashion +image|ecommerce|beauty-routine-scene|美妆日常场景图|把产品置入简洁日常护理台面或浴室场景,包装标签可辨,避免暗示医疗或未证实功效|美妆商品图|场景参考|beauty +image|ecommerce|electronics-lifestyle|数码产品生活方式图|将设备融入真实办公/通勤场景,机身与屏幕不变形,显示内容保持抽象或空白|数码商品图|环境参考|tech +image|ecommerce|product-size-context|商品尺寸参照图|通过常见物体或手持关系帮助理解商品尺寸,比例明确,不虚构标注数值|商品图|尺寸参照物|bag +image|ecommerce|product-collection-grid|商品系列陈列图|为同一系列商品生成统一背景、间距和阴影的整齐组合陈列,避免产品互相遮挡|系列商品图||product +image|ecommerce|beauty-flatlay|美妆平铺组合图|俯拍排布产品、包装和少量真实配件,保持每件商品标签方向与外观清楚|美妆商品图|配件参考|beauty +image|ecommerce|food-ingredient-story|食品原料故事图|以产品和已知原料构建清晰静物叙事,不添加配方中未确认的成分或健康承诺|食品商品图|原料参考图|food +image|drama-character|drama-character-bible|短剧角色定妆立绘|建立可复用的角色外貌锚点:年龄感、脸型、发型、肤色、体态与服装层次固定|角色参考图|服装参考图|man +image|drama-character|drama-turnaround|短剧角色四视图|同一角色生成正面、侧面、背面和四分之三角度,发型、服装、体型和配件一致|角色参考图|服装/道具参考|elder +image|drama-character|drama-expression-sheet|短剧角色表情表|为同一角色生成克制的喜怒惊疑等表情变化,保持脸部身份、发型、年龄与服装不漂移|角色定妆图||fashion +image|drama-character|drama-costume-variants|短剧角色服装变体|为角色设计指定剧情阶段的服装方案,只改变服装与配件,保留角色身份和时代设定|角色定妆图|服装方向参考|elder +image|drama-character|drama-prop-turnaround|短剧关键道具三视图|生成关键道具正侧背与细节参考,锁定形状、材质、磨损、握持方式和剧情状态|道具参考图||film +image|drama-scene|drama-location-master|短剧场景空间母版|建立场景空间母版,明确入口、窗口、主要家具、主光方向和可表演区域|场景参考图|美术风格参考|interior +image|drama-scene|drama-location-coverage|短剧场景多角度|从同一空间生成全景、反打和侧面角度,门窗位置、地标、光线方向与空间轴线保持一致|场景母版|镜头角度参考|city +image|drama-scene|drama-time-variants|短剧场景昼夜变体|同一场景切换清晨/夜晚/雨天等指定时段,只改变时间氛围,空间布局和地标固定|场景母版|时间气氛参考|forest +image|storyboard|drama-storyboard-grid|短剧九宫格分镜|把一段已确认剧情拆为九格关键帧,每格单一动作、机位和情绪节点,人物造型连续|剧本/分镜文字|角色与场景参考|film +image|storyboard|drama-keyframe|短剧镜头关键帧|生成单镜头首帧构图,明确主体位置、视线、手部、持物、景别和画面左右关系|镜头描述|角色/场景参考|city +image|storyboard|drama-blocking-two-shot|短剧双人调度图|呈现双人站位和视线方向,明确画面左/右、前景/后景与摄影轴线,方便连续拍摄|角色参考图|场景母版|interior +image|storyboard|drama-over-shoulder|短剧过肩反打关键帧|生成对话过肩镜头,保持视线轴线、人物相对位置、焦平面和背景地标一致|角色参考图|上一镜关键帧|interior +image|storyboard|drama-continuity-repair|短剧分镜连续性修复|依据上一镜结束状态修正本镜起始状态,锁定服装、伤痕、道具手别、视线和空间方向|上一镜帧|当前镜头草图|film +image|drama-character|drama-identity-lock|短剧角色一致性参考|把多张角色参考整合为一份稳定外貌锚点,明确必须保持的脸型、发型、服装和年龄感|角色多角度图||man +image|drama-character|drama-wardrobe-ledger|短剧服装连续性图|按剧情阶段展示服装状态与可变项,固定角色身份、主色、发型和配件|角色图|服装状态参考|elder +image|drama-scene|drama-weather-continuity|短剧天气光线连续性|为同一场景制作指定天气与主光方向的镜头参考,雨量、地面湿度和光源位置连续|场景参考图|天气氛围参考|city +image|drama-character|drama-period-costume|年代短剧角色造型|按明确年代与身份生成角色造型参考,服装版型、发型、配饰和妆容避免混搭时代|角色参考图|年代服饰参考|elder +image|drama-scene|drama-modern-suspense-set|现代悬疑短剧场景|设计可拍摄的悬疑室内场景,明确出口、遮挡物、光源和关键线索位置|场景参考图|线索道具图|interior +image|short-video|drama-romance-poster|短剧情感封面海报|生成适合竖屏分发的情感短剧封面构图,人物关系清楚,标题区域留白,不生成文字|角色定妆图|气氛参考图|fashion +image|storyboard|drama-reveal-closeup|短剧反转揭示特写|为剧情揭示瞬间构建可读的面部特写,控制视线、微表情、景深和关键道具露出|角色参考图|线索道具图|man +image|storyboard|script-to-storyboard|短剧剧本转分镜画面|按已确认剧本逐镜生成可制作的关键帧,包含景别、动作起止、视线与道具状态|剧本片段|角色/场景资产|film +image|storyboard|comic-drama-panels|漫剧分格画面|将单个剧情节拍拆为连贯漫画格,统一人物造型、阅读顺序和光色,不在画面内生成气泡字|角色设定图|分镜脚本|film +image|drama-scene|drama-set-extension|短剧场景横向扩展|扩展原场景画布供竖屏/横屏裁切,建筑结构、光源方向和地标连续,不增加剧情信息|场景母版||forest +image|storyboard|drama-clue-insert|短剧线索道具插入帧|生成手部与线索道具特写,明确左右手、握持方式、道具朝向与景深焦点|角色参考图|道具参考图|film +image|drama-character|drama-ensemble-lineup|短剧群像关系站位|按人物关系生成群像定妆站位,身高比例、服装身份差异和画面层次清晰|角色参考图|场景参考图|man +image|drama-character|drama-power-pose|短剧人物气场立绘|用体态、视线、服装輪廓和机位体现角色权力关系,避免改变已锁定的面部身份|角色定妆图|姿态参考图|elder +image|short-video|drama-opening-cover|短剧开篇竖屏封面|突出主角与本集冲突信息的视觉焦点,竖屏安全区留白,不生成标题和平台标识|角色定妆图|本集情绪参考|city +image|drama-scene|drama-location-redress|短剧场景陈设变体|在不改变空间结构的前提下更换指定年代/剧情阶段的陈设,固定门窗、轴线和摄影地标|场景母版|陈设参考|interior +image|enhancement|drama-color-script|短剧色彩风格帧|为一集建立统一色彩与光影参考,肤色自然、主色克制,并保留各场景可辨识度|已有关键帧|色彩方向参考|film +image|people|portrait-realism|人物写真真实感增强|修复人像光影、发丝与皮肤细节,保留原人物身份、年龄、肤色和自然纹理|人像原图||fashion +image|scene|background-replace|人物/商品换背景|只替换背景并匹配透视、接触阴影和环境光,主体结构与细节保持不变|主体原图|目标背景图|interior +image|enhancement|image-restoration|图片修复与清晰化|提升清晰度、去除压缩噪点并恢复边缘,不重画文字、商标、五官或商品结构|待修复图片||film +image|enhancement|style-transfer-reference|统一视觉风格参考|把参考图的色彩、笔触、光线和质感迁移到目标画面,构图和主体身份保持|目标图|风格参考图|film +video|ecommerce|product-hero-motion|商品广告英雄出场|单镜头缓慢推进至商品,商品保持静止且标签清晰,背景光线轻微流动,结尾停留完整主视觉|商品首帧|运动参考视频|product +video|ecommerce|product-orbit|商品缓慢环绕展示|镜头围绕静置商品平稳移动约四分之一圈,维持商品结构、透视、包装文字和背景连续|商品首帧|商品多角度参考|tech +video|ecommerce|product-macro-push|商品细节微距推进|镜头缓慢推近指定材质/接口并保持焦点稳定,商品不变形,微距结束时留出清晰细节|商品细节首帧|细节参考图|bag +video|ecommerce|fashion-fabric-motion|服装面料动态展示|模特保持自然站姿,轻微转身带动衣料产生真实褶皱,衣型、印花和颜色锁定|服装首帧|模特参考图|fashion +video|ecommerce|fashion-model-turn|服装模特转身展示|模特从正面缓慢转向侧面并回望镜头,单一连续动作,衣着与身份保持一致|模特首帧|服装参考图|fashion +video|ecommerce|shoe-walk-demo|鞋履步态展示|脚步自然向前两步,镜头低位跟拍鞋面和鞋底,鞋款结构、左右脚及步态连续|鞋履首帧|鞋款参考图|product +video|ecommerce|bag-lifestyle-walk|包袋通勤动态展示|人物自然行走并轻微摆动手提包,镜头平稳跟随,包型、五金与手部接触保持稳定|包袋首帧|模特参考图|bag +video|ecommerce|jewelry-light-motion|珠宝微光动态展示|镜头缓慢侧移,珠宝反射光轻柔掠过,宝石数量、形状、镶口与佩戴位置不变|饰品首帧|饰品细节图|fashion +video|ecommerce|serum-drop-demo|精华滴管产品镜头|滴管缓慢抬起并落下一滴透明液体,瓶身和标签固定,动作简洁、液体比例真实|产品首帧|包装参考图|beauty +video|ecommerce|skincare-routine|护肤品使用动作演示|手部将产品轻放于台面并做一次简单取用动作,包装保持准确,不暗示治疗效果|产品首帧|使用步骤参考|beauty +video|ecommerce|perfume-mist|香水喷雾广告镜头|手指轻按喷头,细雾在侧逆光中短暂显现,瓶体、标签和液体颜色不改变|香水首帧|品牌光线参考|beauty +video|ecommerce|coffee-pour|咖啡冲煮动态镜头|热水稳定注入咖啡滤杯,蒸汽轻微升起,镜头缓慢推进,器具比例和咖啡颜色真实|咖啡首帧|器具参考图|coffee +video|ecommerce|food-steam-closeup|食品热气特写|镜头轻推近刚出锅的菜品,蒸汽自然上升,配料与摆盘保持原样,不突然增减食物|菜品首帧|菜单参考图|food +video|ecommerce|unboxing-sequence|商品开箱动作|双手平稳打开包装并露出商品,包装印刷不变,动作按顺序完成且不跳帧|包装首帧|商品/包装参考图|coffee +video|ecommerce|appliance-action|小家电功能动作展示|设备完成一个明确的开关/运行动作,按键和结构固定,镜头记录状态变化,不生成界面文字|家电首帧|产品细节图|tech +video|ecommerce|furniture-room-reveal|家具空间揭示镜头|镜头从局部平稳后退显露完整房间与家具,空间尺寸可信,家具结构和材质不变|家具首帧|空间参考图|interior +video|ecommerce|electronics-detail-demo|数码产品环绕特写|镜头从机身侧边移动到接口特写,屏幕保持抽象纯色,按钮数量与机身轮廓固定|数码首帧|接口细节图|tech +video|short-video|vertical-product-hook|竖屏商品三秒钩子|开场立即看到商品与一个清晰动作,镜头快速但平稳推进,商品全程在竖屏安全区内|商品首帧|广告节奏参考|product +video|ecommerce|handheld-product-demo|手持商品使用演示|手部拿起商品并展示一个真实使用动作,手指数目自然,包装文字和商品外观锁定|商品首帧|手部/动作参考|bag +video|ecommerce|product-end-card-motion|商品广告收尾停留|镜头轻微推近后稳定停留在完整包装主视觉,背景保持干净,结尾留白供后期加文案|商品首帧|品牌背景参考|beauty +video|ecommerce|spotlight-packshot|商品聚光棚拍动态|背景保持静止,柔和聚光从侧面扫过商品表面并结束于均匀主光,商品不旋转变形|商品首帧||product +video|ecommerce|product-compare-motion|商品前后状态对照镜头|按用户提供的两种真实状态做平稳左右对照,不添加性能结论、数值或未经证实的效果|状态A首帧|状态B参考图|tech +video|ecommerce|product-360-still|单品静帧转展示视频|以商品原图为首帧,只做缓慢镜头环绕/推拉与灯光变化,严禁重绘商品细节|商品首帧|商品参考图|product +video|drama-character|drama-character-entrance|短剧主角入场镜头|角色从画面边缘走入并在指定地标前停下,服装、发型、持物和画面方向连续|镜头首帧|角色定妆图|man +video|storyboard|drama-single-dialogue|短剧单人对白镜头|角色说出一小句台词并完成一个微表情,视线固定在画外对手方向,镜头缓慢推近|镜头首帧|角色参考图|elder +video|storyboard|drama-reaction-closeup|短剧反应特写|角色听到信息后先保持静止再出现克制的表情变化,目光落点清晰,机位稳定|镜头首帧|角色定妆图|fashion +video|storyboard|drama-two-person-dialogue|短剧双人对话调度|两人按首帧左右位置进行一轮简短交流,只移动说话者的视线与轻微手势,摄影轴线不变|双人首帧|角色参考图|interior +video|storyboard|drama-ots-reverse|短剧过肩反打镜头|从肩后轻微移焦至对手反应,保持视线方向、前后景和人物站位与参考镜头一致|过肩首帧|反打构图参考|interior +video|drama-character|drama-walk-up-turn|短剧人物走近回身|角色向镜头走两步并在标记处回身,动作单一连贯,服饰、手中道具与背景地标锁定|角色首帧|场景母版|city +video|storyboard|drama-clue-reveal|短剧线索揭示镜头|镜头由人物视线平移至关键道具并停留,线索保持可读,手部和道具位置不跳变|线索首帧|道具参考图|film +video|storyboard|drama-hand-insert|短剧手部道具特写|手从画面边缘拿起指定物件并翻转一次,左右手和握持关系正确,镜头保持近景|手部首帧|道具参考图|film +video|drama-scene|drama-rain-window|短剧雨窗氛围镜头|窗外雨滴缓慢滑落,室内角色仅有轻微呼吸动作,灯光方向、窗框和空间布局保持不变|场景首帧|角色参考图|city +video|drama-scene|drama-night-establishing|短剧夜景建立镜头|镜头沿街道缓慢前移建立地点,招牌不生成可读文字,地标、天气和光源位置稳定|场景首帧|地点参考图|city +video|drama-scene|drama-corridor-follow|短剧走廊跟拍镜头|镜头从角色身后平稳跟随其沿走廊前行,门窗顺序与空间纵深连续,不穿越墙体|走廊首帧|角色参考图|interior +video|storyboard|drama-look-back|短剧悬念回望镜头|角色听见画外声后停步并缓慢回头,表情克制,机位与视线落点清晰|镜头首帧|角色定妆图|elder +video|storyboard|drama-flashback-transition|短剧回忆转场镜头|以一次柔和曝光/焦点变化进入回忆画面,不改变角色身份,转场前后动作衔接清楚|当前镜头首帧|回忆场景参考|film +video|drama-character|drama-romance-meet|短剧情感相遇镜头|两位角色在同一空间短暂停步并交换视线,动作自然克制,人物位置和背景关系稳定|双人首帧|角色参考图|fashion +video|storyboard|drama-confrontation|短剧对峙调度镜头|两人保持安全距离进行克制对峙,仅有一步靠近或手势变化,摄影轴线与地标不变|双人首帧|场景母版|interior +video|drama-character|drama-fantasy-reveal|短剧奇幻角色揭示|镜头缓慢揭示角色轮廓与环境光效,角色面部、服饰和身体结构不改变,动作单一|角色首帧|风格参考图|forest +video|storyboard|drama-keyframe-animation|短剧关键帧图生视频|将关键帧里的一个明确动作扩展成短镜头,保持角色、服装、道具、场景和机位连续|冻结关键帧|角色/场景参考|film +video|storyboard|drama-first-last-frame|短剧首尾帧过渡|严格从指定首帧移动到指定尾帧,只补足两帧间合理动作,不新增人物或改变道具状态|首帧|尾帧|city +video|short-video|drama-vertical-hook|竖屏短剧开场钩子|用一个清晰动作和快速建立的情绪焦点抓住前三秒,主体处在竖屏安全区,背景信息简洁|开场首帧|角色参考图|man +video|storyboard|drama-lipsync-line|短剧单句口型表演|角色对镜头外对象说出指定短句,嘴部动作自然,台词不扩写,表情与角色设定一致|对白首帧|角色/音色说明|elder +video|drama-scene|drama-ending-hold|短剧结尾悬念停帧|角色完成一个克制动作后停留在悬念构图,镜头稳定保持结尾画面,不自行揭示答案|结尾首帧|角色/道具参考|film +video|short-video|image-to-video-motion|参考图轻动作动画|保留参考图构图与主体身份,仅添加呼吸、微风或轻微镜头运动等一种低幅度动作|参考首帧|运动參考视频|forest +video|scene|flower-motion-study|花植轻风运镜|以花朵近景为主体,微风带动花瓣和叶片轻轻摆动,镜头缓慢侧移并保持柔和自然光|花植首帧|运动参考视频|forest +video|scene|environment-reveal|环境空间揭示视频|从局部平稳移动到完整空间全景,建筑结构、地标、透视和光源方向保持连贯|环境首帧|空间参考图|interior +video|short-video|camera-motion-study|镜头运动测试|对同一静态主体测试一种明确机位运动,主体不变形,背景透视连续,不叠加多余动作|测试首帧|机位参考图|film +video|short-video|abstract-transition|短片氛围转场|在两个给定画面之间用一种克制的光线/焦点转场连接,保留主体轮廓与色彩关系|转场首帧|目标帧|city +`.trim().split("\n").map((line) => line.split("|")); + +const imageIds: Record = { ...imageRefs }; +const imageUrls: Record = { + product: "https://images.unsplash.com/photo-1542291026-7eec264c27ff", + fashion: "https://images.unsplash.com/photo-1534528741775-53994a69daeb", + beauty: "https://images.unsplash.com/photo-1608248543803-ba4f8c70ae0b", + bag: "https://images.unsplash.com/photo-1548036328-c9fa89d128fa", + coffee: "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085", + food: "https://images.unsplash.com/photo-1546549032-9571cd6b27df", + tech: "https://images.unsplash.com/photo-1606220945770-b5b6c2c55bf1", + man: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e", + elder: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d", + city: "https://images.unsplash.com/photo-1519608487953-e999c86e7455", + interior: "https://images.unsplash.com/photo-1600210492486-724fe5c67fb0", + forest: "https://images.unsplash.com/photo-1448375240586-882707db888b", + film: "https://images.unsplash.com/photo-1485846234645-a62644f84728", +}; + +const tools = rows.map(([kind, categoryId, key, name, focus, inputOne, inputTwo, sampleKey], index) => { + const sampleResourceId = imageIds[sampleKey] || imageIds.product; + const drama = categoryId.startsWith("drama-") || categoryId === "storyboard"; + const video = kind === "video"; + const keywords = [...new Set([name, categoryId, drama ? "短剧" : "电商", video ? "图生视频" : "生图", ...focus.split(/[,、; ]/).filter((word) => word.length > 1).slice(0, 8)])].slice(0, 20); + const slots = [{ + key: "reference-main", name: inputOne, mediaTypes: [inputOne.includes("视频") ? "video" : "image"], resourceKind: "asset", minCount: 1, maxCount: 4, required: true, + coverResourceId: sampleResourceId, sampleResourceId, + }]; + if (inputTwo) slots.push({ + key: "reference-support", name: inputTwo, mediaTypes: [inputTwo.includes("视频") ? "video" : "image"], resourceKind: "asset", minCount: 0, maxCount: 4, required: false, + coverResourceId: sampleResourceId, sampleResourceId, + }); + const imagePrompt = `以参考图1为主体外观与身份的唯一依据,${focus}。保持主体结构、颜色、比例、材质和可见细节稳定;参考图2仅用于其对应的场景/姿态/风格,不得覆盖主体特征。采用清晰的主体层级、可信透视、自然光影与真实接触关系。画面不生成文字、价格、水印、虚构品牌或未提供的产品信息;输出干净、可用于商业创作的${drama ? "短剧资产/分镜参考" : "电商视觉"}。`; + const videoPrompt = `单镜头短片,${focus}。${inputOne}决定主体身份与首帧构图;若有第二参考,只用于对应动作/风格。按“起始状态→一个清晰动作→稳定结束状态”连续演完,镜头运动克制且明确,主体脸、服装、商品结构、道具手别、空间轴线与光源方向全程一致。动作幅度真实,背景不跳变,不新增人物/物件/文字,不发生形变、闪烁或瞬移;结尾停留约1秒,便于剪辑。`; + const referenceList = drama ? sources.drama : sources.ecommerce; + const guideLines = [ + `准备:上传${inputOne}${inputTwo ? `;可补充${inputTwo}` : ""}。先选定主体/角色锁定信息,再填写本次${video ? "动作与机位" : "构图、光线和风格"}要求。`, + `生成:${focus}。每次只改一个变量;涉及同一 SKU/角色/场景的多张图时,复用相同参考并把身份锚点原样带入。`, + `验收:检查${drama ? "脸/服装/持物/视线/轴线/场景地标连续性" : "商品轮廓/颜色/包装文字/配件/比例/阴影真实性"};不确定的规格、卖点和文字留空交给后期核实。`, + ]; + const imageExampleText = `Unsplash 风格参考照片(不是本工具生成结果)。素材源:${imageUrls[sampleKey] || imageUrls.product};用于展示输入/构图方向,实际结果需以所选模型生成为准。`; + const presets = video ? [ + { name: "标准单镜头", summary: "一个主体动作 + 一种机位运动 + 稳定尾帧", promptTemplate: videoPrompt, enabled: true, sortOrder: 0 }, + { name: "情绪强化", summary: "增强表演节奏与光线,不增加动作", promptTemplate: `${videoPrompt} 情绪方向:从克制开始,在动作完成时出现清楚但不过度的情绪变化;使用单一色温与柔和层次光,避免快速剪切。`, enabled: true, sortOrder: 1 }, + { name: "干净产品/连续性版", summary: "优先保真与稳定,降低背景运动", promptTemplate: `${videoPrompt} 保真优先:镜头固定或极慢推进,背景保持静止,主体占画面主要区域,所有标签/服装/道具细节清晰可核对。`, enabled: true, sortOrder: 2 }, + ] : [ + { name: "标准主视觉", summary: "主体保真,按核心用途生成", promptTemplate: imagePrompt, enabled: true, sortOrder: 0 }, + { name: "构图变化", summary: "锁定主体,只调整机位和留白", promptTemplate: `${imagePrompt} 构图变体:采用轻微三分法机位,将主体放在画面一侧,另一侧留出干净负空间;主体仍完整可辨,画面层次简洁。`, enabled: true, sortOrder: 1 }, + { name: "细节优先", summary: "适合材质、五金、表情与连续性检查", promptTemplate: `${imagePrompt} 细节版:镜头略近,优先表现真实纹理、边缘、手部/面部自然结构与接触阴影;不锐化出虚假纹理,不改变主体身份。`, enabled: true, sortOrder: 2 }, + ]; + const exampleDescription = key === "flower-motion-study" + ? `${imageExampleText} 另附 MDN CC0 花卉运动样片,仅作动态参考,不是本工具生成结果。` + : imageExampleText; + const resultExample = { + title: `${name}参考示例`, + description: exampleDescription, + imageResourceIds: [sampleResourceId], + ...(key === "flower-motion-study" ? { videoResourceId: "360faf53e9d314f9f94eb69fb429ed5f" } : {}), + }; + return { + seedKey: `catalog-2026-10-${key}`, + kind, categoryId, name, + subtitle: focus.slice(0, 80), + description: `${focus}。${video ? "适合图生视频/镜头预演,具体时长和画幅以所选模型能力为准。" : "适合先以参考图锁定主体,再按场景与版式生成;商品/角色细节需人工验收。"}`, + iconResourceId: sampleResourceId, + coverResourceId: sampleResourceId, + sortOrder: 1000 + index * 10, + enabled: true, + capability: { + supportedModes: [kind], searchKeywords: keywords, surfaces: ["workbench"], + recommended: index < 12, recommendationOrder: index + 1, catalogSeedKey: `catalog-2026-10-${key}`, + inputContract: { promptRequired: true, minTextReferences: 0, slots }, + }, + guide: { + inputHint: `${inputOne}${inputTwo ? `;可选${inputTwo}` : ""}。${focus}`.slice(0, 500), + lines: guideLines, + resultExample, + researchSources: [...referenceList, ...(video ? sources.video : [])], + sampleDisclosure: "示例图片来自 Unsplash,已导入本平台资源;均为风格/输入参考,不宣称是模型生成输出。提示词为结合开源工作流与官方指南整理的可编辑模板。", + }, + presets, + }; +}); + +const payload = { + schemaVersion: 1, + researchedOn: "2026-10-12", + counts: { total: tools.length, image: tools.filter((tool) => tool.kind === "image").length, video: tools.filter((tool) => tool.kind === "video").length }, + sourceNotes: [ + "GitHub/ecommerce-skills:商品白底、上身、多角度、套图一致性与细节场景工作流。", + "GitHub/zenstory-ai/drama-skills:故事资产、角色/场景锁定、分镜冻结、单镜视频提示词与连续性检查。", + "Runway Gen-4 与 Google Veo 官方提示指南:主体动作、场景、景别、镜头运动、光线/氛围与单镜头迭代。", + "Reddit/ComfyUI 社区经验仅作为低权重补充;商品主图/品牌标签必须用实物图人工核验。", + ], + sources: [...new Set([...sources.ecommerce, ...sources.drama, ...sources.video])], + sampleImageSources: Object.fromEntries(Object.entries(imageUrls).map(([key, url]) => [key, `${url}?auto=format&fit=crop&w=720&q=82`])), + tools, +}; + +Deno.serve({ + hostname: "127.0.0.1", + port: 4174, + handler(request) { + if (new URL(request.url).pathname !== "/catalog.json") return new Response("not found", { status: 404, headers: { "Access-Control-Allow-Origin": "*" } }); + return Response.json(payload, { headers: { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store" } }); + }, +}); +console.log(`Workbench seed ready at http://localhost:4174/catalog.json (${tools.length} entries)`); diff --git a/web/src/components/auth/require-feature.tsx b/web/src/components/auth/require-feature.tsx index b6315210c..2d367d123 100644 --- a/web/src/components/auth/require-feature.tsx +++ b/web/src/components/auth/require-feature.tsx @@ -7,7 +7,7 @@ import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/co import { refreshFeatureAvailability } from "@/lib/user-session"; import { useUserStore } from "@/stores/use-user-store"; -type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled"; +type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled"; const featureNames: Record = { shortDramaEnabled: "短剧创作", @@ -15,6 +15,8 @@ const featureNames: Record = { creditsEnabled: "积分中心", frontendModelsEnabled: "前台模型", pluginCenterEnabled: "插件中心", + imageWorkbenchEnabled: "生图", + videoWorkbenchEnabled: "视频", }; export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) { @@ -39,8 +41,18 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi }; }, [feature, user?.id]); - if (checking) return ; - if (error) return navigate("/", { replace: true })} />; + if (checking) + return ( + + + + ); + if (error) + return ( + + navigate("/", { replace: true })} /> + + ); if (!features[feature]) { // 管理员页面返回到管理后台首页,用户页面返回到创作台 const isAdminFeature = feature === "frontendModelsEnabled"; @@ -49,7 +61,16 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi return ( - navigate(backPath, { replace: true })}>{backLabel}} /> + navigate(backPath, { replace: true })}> + {backLabel} + + } + /> ); } diff --git a/web/src/components/layout/workspace-sidebar-nav.tsx b/web/src/components/layout/workspace-sidebar-nav.tsx index 476bea418..90215cbe5 100644 --- a/web/src/components/layout/workspace-sidebar-nav.tsx +++ b/web/src/components/layout/workspace-sidebar-nav.tsx @@ -1,5 +1,5 @@ import { Popover } from "antd"; -import { ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus } from "lucide-react"; +import { ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Image as ImageIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus, Video } from "lucide-react"; import { LayoutGroup, motion, useReducedMotion } from "motion/react"; import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react"; import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; @@ -46,15 +46,26 @@ function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[]; items: [ { ...toolItem("create", "/"), id: "home", title: "创作" }, { ...toolItem("inspirations", "/inspirations"), title: "灵感" }, - { ...toolItem("projects", "/projects"), title: "短剧 Agent" }, + { ...toolItem("projects", "/projects"), title: "短剧制作" }, { ...toolItem("canvas", "/canvas"), title: "自由画布" }, ], }, { heading: "资源与工具", - items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])], + items: [ + ...(features.imageWorkbenchEnabled ? [{ id: "toolbox-image", title: "图片生成", icon: ImageIcon, to: "/toolbox/image" }] : []), + ...(features.videoWorkbenchEnabled ? [{ id: "toolbox-video", title: "视频生成", icon: Video, to: "/toolbox/video" }] : []), + { ...toolItem("skills", "/skills"), title: "技能" }, + ...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : []), + ], + }, + { + heading: "资产与历史", + items: [ + { ...toolItem("assets", "/assets"), title: "资产" }, + ...(features.taskCenterEnabled ? [{ id: "tasks", title: "创作历史", icon: ImageIcon, to: "/tasks" }] : []), + ], }, - ...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []), ]; // 管理、设置和退出登录不再占据参考站式侧栏底部,而是通过用户卡片菜单进入。 @@ -67,10 +78,25 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co const profileName = user?.displayName || user?.username || "未登录"; if (!user) { - return {!collapsed ? 游客模式 : null}; + return ( + + + + + {!collapsed ? 游客模式 : null} + + ); } - const content = setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />; + const content = ( + setMenuOpen(false)} + onWallet={() => { + setMenuOpen(false); + openWorkspaceWallet(); + }} + /> + ); return (
@@ -80,16 +106,25 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co - {!collapsed ? : null} + {!collapsed ? ( + + ) : null}
- {collapsed ? : null} + {collapsed ? ( + + ) : null} ); } @@ -101,7 +136,12 @@ function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNa return (
- } /> + } + />
); @@ -111,7 +151,12 @@ function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNa
- } /> + } + /> {appearance.brandName} 创作工作台 @@ -152,7 +197,8 @@ function NavItem({ const Icon = item.icon; const rowStyle = collapsed ? undefined : ({ paddingLeft: `${level * 12 + 10}px` } as CSSProperties); - const collapsedTitle = item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2); + const collapsedTitle = + item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2); const rowContent = ( <> @@ -166,9 +212,7 @@ function NavItem({ {collapsed ? collapsedTitle : item.title} - {item.shortcut ? ( - {item.shortcut} - ) : null} + {item.shortcut ? {item.shortcut} : null} {item.badge !== undefined ? {item.badge} : null} {hasChildren ? : null} @@ -180,14 +224,7 @@ function NavItem({ collapsed && "is-collapsed", isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground", ); - const activePill = isActive ? ( - - ) : null; + const activePill = isActive ? : null; const handleClick = () => { if (item.action === "search") { @@ -227,7 +264,16 @@ function NavItem({ {rowContent} ) : ( - @@ -266,7 +312,11 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps // 无标题分组(核心导航入口)常驻展示,不做折叠。 if (!group.heading || collapsed) { - return
{content}
; + return ( +
+ {content} +
+ ); } return ( @@ -282,6 +332,15 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps ); } +// 菜单项 id 与实际路由不一定同名:/toolbox/image 和 /toolbox/video 共用 toolbox 前缀, +// 只取首段会让两者都匹配不到任何菜单项,侧栏因此没有高亮。 +export function workspaceNavActiveId(pathname: string, section?: string | null) { + const segments = pathname.split("/").filter(Boolean); + if (segments[0] === "toolbox" && segments[1]) return `toolbox-${segments[1]}`; + const slug = segments[0] || "home"; + return slug === "settings" && section ? `settings:${section}` : slug; +} + export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void; onCollapse: () => void }) { const { pathname } = useLocation(); const [searchParams] = useSearchParams(); @@ -290,9 +349,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp const { handleLogout } = useWorkspaceLogout(); const { groups, footer } = useMemo(() => buildNav(features), [features]); - const slug = pathname.split("/").filter(Boolean)[0] || "home"; - const section = searchParams.get("section"); - const activeId = slug === "settings" && section ? `settings:${section}` : slug; + const activeId = workspaceNavActiveId(pathname, searchParams.get("section")); const scrollRef = useRef(null); const [scrollState, setScrollState] = useState({ hasTopFade: false, hasBottomFade: false }); @@ -314,24 +371,31 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp -
- {groups.map((group, index) => ( - void handleLogout()} collapsed={collapsed} /> - ))} -
+
+ {groups.map((group, index) => ( + void handleLogout()} collapsed={collapsed} /> + ))} +
- {footer.length ?
- {footer.map((item) => ( - void handleLogout()} collapsed={collapsed} /> - ))} -
: null} + {footer.length ? ( +
+ {footer.map((item) => ( + void handleLogout()} collapsed={collapsed} /> + ))} +
+ ) : null}
); diff --git a/web/src/components/layout/workspace-top-bar.tsx b/web/src/components/layout/workspace-top-bar.tsx index 31a3af576..49c9d1a5c 100644 --- a/web/src/components/layout/workspace-top-bar.tsx +++ b/web/src/components/layout/workspace-top-bar.tsx @@ -16,7 +16,7 @@ const PAGE_TITLES: Record = { home: "创作", create: "创作", inspirations: "灵感", - projects: "短剧 Agent", + projects: "短剧制作", canvas: "自由画布", tasks: "创作历史", assets: "资产", diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 2607117ce..4c6d4f6c5 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -14,6 +14,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { ModelLogo } from "@/components/model-logo"; import { ModelTags } from "@/components/model-tags"; +import { videoReferenceCapabilityTag } from "@/lib/model-tags"; import { quoteModel, type LogicalModelQuote, type PublicChannelModelAvailability } from "@/services/api/logical-models"; type ModelPickerProps = { @@ -331,6 +332,8 @@ export function ModelLabel({ const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model)); const logicalSpec = logicalCost?.logicalCapabilitySpec; const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined; + const capabilityTag = videoReferenceCapabilityTag(videoProfile?.references); + const modelTags = capabilityTag ? [capabilityTag, ...(logicalCost?.tags || [])] : logicalCost?.tags; const capabilitySummary = disabledReason || logicalCost?.description?.trim() || @@ -354,7 +357,7 @@ export function ModelLabel({ {capabilitySummary} - + {!creationVariant && meta.time ? ( diff --git a/web/src/lib/model-tags.ts b/web/src/lib/model-tags.ts index e0bd634a4..7179567d8 100644 --- a/web/src/lib/model-tags.ts +++ b/web/src/lib/model-tags.ts @@ -8,3 +8,17 @@ export const modelTagColors = [ ] as const; export type ModelTag = { text: string; color: typeof modelTagColors[number]["value"] }; + +export function videoReferenceCapabilityTag(references?: { maxImages?: number; maxVideos?: number; maxAudios?: number }): ModelTag | undefined { + if (!references) return undefined; + const counts = [ + ["图", references.maxImages], + ["视", references.maxVideos], + ["音", references.maxAudios], + ] as const; + const text = counts + .filter(([, count]) => typeof count === "number" && Number.isFinite(count) && count > 0) + .map(([label, count]) => `${label}×${count}`) + .join(" · "); + return text ? { text, color: "green" } : undefined; +} diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 4a85b2211..e33cd8de5 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -35,6 +35,7 @@ import { Sun, TicketCheck, ToggleLeft, + WandSparkles, UsersRound, } from "lucide-react"; import { Suspense, useEffect, useState, type ReactNode } from "react"; @@ -77,6 +78,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = { path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: }, { path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: , requireFeature: "frontendModelsEnabled" }, { path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: }, + { path: "/admin/workbench-tools", label: "工作台工具", description: "生图/视频工具与预设", icon: }, { path: "/admin/resources", label: "存储资源", description: "资源列表、容量与预览", icon: }, ], }, diff --git a/web/src/pages/admin/components/channel-model-editor-form.ts b/web/src/pages/admin/components/channel-model-editor-form.ts index 583e8897e..a3b2d4667 100644 --- a/web/src/pages/admin/components/channel-model-editor-form.ts +++ b/web/src/pages/admin/components/channel-model-editor-form.ts @@ -4,7 +4,7 @@ import { imagePresetForRatio, imageSizePresets } from "@/lib/image-size-presets" import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols"; import type { ChannelModel } from "@/services/api/wallet"; import type { ModelTag } from "@/lib/model-tags"; -import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form"; +import { defaultPriceTier, legacyPriceTierToForm, normalizeUpstreamModelKey, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form"; export type ChannelModelFormValues = { modelKey: string; @@ -47,6 +47,17 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols: }; } +export function duplicateChannelModelValues(item: ChannelModel, protocols: ModelProtocolDefinition[]): ChannelModelFormValues { + const values = initialChannelModelValues(item, protocols); + const sourceUpstream = normalizeUpstreamModelKey(item.providerModelKey || item.modelKey); + return { + ...values, + modelKey: "", + providerModelKey: "", + priceTiers: values.priceTiers.map((tier) => (normalizeUpstreamModelKey(tier.providerModelKey) === sourceUpstream ? { ...tier, providerModelKey: "" } : tier)), + }; +} + // Clear incompatible selectors, but never silently convert prices or billing units. export function changeChannelModelCapability(values: ChannelModelFormValues, protocols: ModelProtocolDefinition[]): ChannelModelFormValues { const capability = values.capability; diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx index 6514757e3..02d42b33b 100644 --- a/web/src/pages/admin/components/channel-model-editor.tsx +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -19,6 +19,7 @@ import { changeChannelModelCapability, editorSectionForField, initialChannelMode export function ChannelModelEditor({ channel, editing, + draftValues, protocols, protocolLoading, protocolError, @@ -28,6 +29,7 @@ export function ChannelModelEditor({ }: { channel: ModelChannel; editing: ChannelModel | null; + draftValues?: FormValues; protocols: ModelProtocolDefinition[]; protocolLoading: boolean; protocolError: string; @@ -41,7 +43,7 @@ export function ChannelModelEditor({ .sort((a, b) => a.localeCompare(b, "zh-CN")) .map((value) => ({ value })); const [form] = Form.useForm(); - const [initialValues] = useState(() => initialChannelModelValues(editing, protocols)); + const [initialValues] = useState(() => draftValues ?? initialChannelModelValues(editing, protocols)); const [activeSection, setActiveSection] = useState("identity"); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -177,9 +179,9 @@ export function ChannelModelEditor({ open title={
- {editing ? "编辑模型" : "新增模型"} + {draftValues ? "复制模型" : editing ? "编辑模型" : "新增模型"}

- {channel.name} · {editing?.displayName || editing?.modelKey || "配置调用方式与用户积分价格"} + {channel.name} · {editing?.displayName || editing?.modelKey || draftValues?.displayName || "配置调用方式与用户积分价格"}

} diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 606a51fd0..fc005a61a 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { App, Button, Checkbox, Input, Modal, Popconfirm, Space } from "antd"; +import { App, Button, Checkbox, Input, Modal, Popconfirm, Space, Tooltip } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Pencil, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; +import { Copy, Pencil, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { PaginationBar } from "@/pages/admin/components/admin-ui"; import { ModelIcon } from "@/components/model-picker"; @@ -15,6 +15,7 @@ import { ChannelOrderDialog } from "./channel-order-dialog"; import { ChannelModelCostSummary } from "./channel-model-cost-summary"; import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog"; import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "./channel-model-import-selection"; +import { duplicateChannelModelValues, type ChannelModelFormValues } from "./channel-model-editor-form"; import { Select } from "@/components/ui/base/select"; export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise }) { @@ -24,6 +25,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan const [deletingSelected, setDeletingSelected] = useState(false); const [repriceItems, setRepriceItems] = useState([]); const [editing, setEditing] = useState(null); + const [copyDraft, setCopyDraft] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); const [fetching, setFetching] = useState(false); @@ -73,6 +75,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan void reload(); void loadProtocols(); setEditing(null); + setCopyDraft(null); setEditorOpen(false); setSelectedModelIds([]); setRepriceItems([]); @@ -137,10 +140,17 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan const startCreate = () => { setEditing(null); + setCopyDraft(null); setEditorOpen(true); }; const startEdit = (item: ChannelModel) => { setEditing(item); + setCopyDraft(null); + setEditorOpen(true); + }; + const startCopy = (item: ChannelModel) => { + setEditing(null); + setCopyDraft(duplicateChannelModelValues(item, availableProtocols)); setEditorOpen(true); }; @@ -221,13 +231,16 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan { title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => }, { title: "操作", - width: 120, + width: 130, fixed: "right", render: (_, item) => ( - + + + + {value.id && } + + ; +} + +function SlotEditor({ slot, index, kind, disabled, upload, onChange, onRemove }: { slot: WorkbenchReferenceSlot; index: number; kind: WorkbenchKind; disabled: boolean; upload: UploadMedia; onChange: (patch: Partial) => void; onRemove: () => void }) { + return
+
+ +
+ 当前类型还没有工具,使用“新建工具”创建首个目录项。
} /> + + !editorBusy && setEditorOpen(false)} onOk={() => void save()} okText={uploadCount ? "等待上传完成" : "保存"} okButtonProps={{ disabled: uploadCount > 0 }} cancelButtonProps={{ disabled: editorBusy }} confirmLoading={saving} maskClosable={!editorBusy} keyboard={!editorBusy} closable={!editorBusy} width={1080} destroyOnHidden className="admin-workbench-tool-editor"> +
+
+ + + + + +
+ +
+ setDraft((current) => ({ ...current, iconResourceId: value.id, iconUrl: value.url }))} /> + setDraft((current) => ({ ...current, coverResourceId: value.id, coverUrl: value.url }))} /> +
+
用户需要提供什么配置模特、人脸、商品等输入,用户端按媒体类型和数量展示上传入口。
+
+
{draft.inputContract.slots.map((slot, index) => updateSlot(slot.key, patch)} onRemove={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.filter((item) => item.key !== slot.key) } }))} />)}
+
使用说明与展示
+
+ + +
+
{draft.resultExample.imageResourceIds?.map((id, index) => setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, imageResourceIds: current.resultExample.imageResourceIds?.flatMap((item, i) => i === index ? value.id ? [value.id] : [] : [item]), imageUrls: current.resultExample.imageResourceIds?.flatMap((_, i) => i === index ? value.id ? [value.url || ""] : [] : [current.resultExample.imageUrls?.[i] || ""]) } }))} />)}
+
+ {(draft.resultExample.imageResourceIds?.length || 0) < 8 && value.id && setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, imageResourceIds: [...(current.resultExample.imageResourceIds || []), value.id!], imageUrls: [...(current.resultExample.imageUrls || []), value.url || ""] } }))} />} + {draft.kind === "video" && setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, videoResourceId: value.id, videoUrl: value.url } }))} />} +
+
预设提示词与工具一起保存;已保存的预设可停用,历史记录保留。
+ {draft.presets.map((preset, index) =>
updatePreset(preset.id, { name: event.target.value })} /> updatePreset(preset.id, { summary: event.target.value })} /> updatePreset(preset.id, { promptTemplate: event.target.value })} /> updatePreset(preset.id, { enabled })} />{!draft.savedPresetIds.includes(preset.id) && }
)} +
+
+
+ ; +} diff --git a/web/src/pages/admin/workbench-tools/workbench-tools.css b/web/src/pages/admin/workbench-tools/workbench-tools.css new file mode 100644 index 000000000..cecc23274 --- /dev/null +++ b/web/src/pages/admin/workbench-tools/workbench-tools.css @@ -0,0 +1 @@ +.admin-workbench-tools{display:flex;min-height:0;flex-direction:column;gap:14px}.admin-workbench-tools-toolbar{display:flex;align-items:center;gap:10px}.admin-workbench-tools-toolbar .ant-select{width:150px}.admin-workbench-tools-toolbar .ant-input-affix-wrapper{max-width:360px}.admin-workbench-tool-main{display:flex;align-items:center;gap:10px;text-align:left}.admin-workbench-tool-icon{display:grid;width:38px;height:38px;flex:none;place-items:center;overflow:hidden;border-radius:10px;background:var(--admin-layer-2);color:var(--admin-text-muted)}.admin-workbench-tool-icon img{width:100%;height:100%;object-fit:cover}.admin-workbench-tool-main strong,.admin-workbench-tool-main small{display:block}.admin-workbench-tool-main small{max-width:420px;overflow:hidden;color:var(--admin-text-muted);text-overflow:ellipsis;white-space:nowrap}.admin-workbench-tool-status{display:flex;align-items:center;justify-content:center;gap:10px}.admin-workbench-tools-empty{padding:48px 16px;text-align:center;color:var(--admin-text-muted)}.admin-workbench-tool-editor .ant-modal-body{max-height:min(76vh,860px);overflow:auto}.admin-workbench-tool-form{padding:4px 2px}.admin-workbench-tool-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.admin-workbench-resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:20px}.admin-workbench-resource-upload{display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--admin-border-subtle);border-radius:12px;background:var(--admin-layer-1)}.admin-workbench-resource-preview{display:grid;width:72px;height:72px;flex:none;place-items:center;overflow:hidden;border-radius:10px;background:var(--admin-layer-2);color:var(--admin-text-muted)}.admin-workbench-resource-preview img,.admin-workbench-resource-preview video{width:100%;height:100%;object-fit:cover}.admin-workbench-resource-actions{display:flex;min-width:0;flex:1;flex-direction:column;gap:4px}.admin-workbench-resource-actions strong,.admin-workbench-resource-actions small{display:block}.admin-workbench-resource-actions small{color:var(--admin-text-muted)}.admin-workbench-resource-actions>div{display:flex;gap:8px;margin-top:4px}.admin-workbench-section-title{display:flex;flex-direction:column;gap:3px;margin:20px 0 12px;padding-top:16px;border-top:1px solid var(--admin-border-subtle)}.admin-workbench-section-title small{color:var(--admin-text-muted)}.admin-workbench-contract-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:10px}.admin-workbench-contract-toolbar .ant-input-number,.admin-workbench-contract-toolbar .ant-input{max-width:180px}.admin-workbench-slots{display:flex;flex-direction:column;gap:10px}.admin-workbench-slot-row{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:12px;background:var(--admin-layer-2)}.admin-workbench-slot-fields{display:grid;grid-template-columns:1fr 1fr 1.25fr 130px 130px auto;align-items:center;gap:8px}.admin-workbench-slot-assets{display:flex;align-items:center;gap:8px}.admin-workbench-slot-assets small{color:var(--admin-text-muted)}.admin-workbench-example-actions{display:flex;gap:8px;margin:4px 0 12px}.admin-workbench-example-previews{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px}.admin-workbench-example-previews>div{position:relative;display:flex;flex-direction:column;gap:5px}.admin-workbench-example-previews img,.admin-workbench-example-previews video{width:120px;height:84px;object-fit:cover;border-radius:8px;background:var(--admin-layer-2)}.admin-workbench-presets{margin-top:22px;padding-top:16px;border-top:1px solid var(--admin-border-subtle)}.admin-workbench-presets>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.admin-workbench-presets>header strong,.admin-workbench-presets>header small{display:block}.admin-workbench-presets>header small{margin-top:3px;color:var(--admin-text-muted)}.admin-workbench-preset-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(140px,1fr) minmax(240px,2fr) auto auto;align-items:start;gap:8px;margin-bottom:10px;padding:12px;border-radius:10px;background:var(--admin-layer-2)}@media(max-width:900px){.admin-workbench-slot-fields{grid-template-columns:1fr 1fr 1fr}.admin-workbench-contract-toolbar{flex-wrap:wrap}}@media(max-width:760px){.admin-workbench-tools-toolbar{flex-wrap:wrap}.admin-workbench-tools-toolbar .ant-input-affix-wrapper{max-width:none;flex:1}.admin-workbench-tool-grid,.admin-workbench-resource-grid{grid-template-columns:1fr}.admin-workbench-slot-fields{grid-template-columns:1fr 1fr}.admin-workbench-preset-row{grid-template-columns:1fr 1fr}.admin-workbench-preset-row textarea{grid-column:1/-1}} \ No newline at end of file diff --git a/web/src/pages/workbench/image-workbench-page.tsx b/web/src/pages/workbench/image-workbench-page.tsx new file mode 100644 index 000000000..cf1e8243a --- /dev/null +++ b/web/src/pages/workbench/image-workbench-page.tsx @@ -0,0 +1,1243 @@ +import { App, Button, Input, InputNumber, Modal, Popover, Progress, Segmented, Select, Spin, Switch, Tooltip } from "antd"; +import { Check, ChevronDown, Expand, FileImage, History, ImagePlus, LoaderCircle, Mic, Plus, Search, Sparkles, Upload, Video, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router"; + +import { ImageSizePicker } from "@/components/image-size-picker"; +import { MediaPreview } from "@/components/media-preview"; +import { ModelPicker } from "@/components/model-picker"; +import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; +import { WorkspacePage } from "@/components/layout/workspace-page"; +import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; +import { normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { imageResolutionUsesQuality } from "@/lib/image-size-presets"; +import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue } from "@/lib/model-capabilities"; +import { creationVideoConfig } from "@/pages/create/creation-generation-config"; +import { imageQualityLabel } from "@/lib/image-quality"; +import { modelQuoteRequest } from "@/lib/model-pricing"; +import { quoteModel } from "@/services/api/logical-models"; +import { submitBackendGenerationTask } from "@/services/api/generation-task"; +import { cancelGenerationTask, queryGenerationTask, retryGenerationTask, type GenerationTask } from "@/services/api/task-center"; +import { capabilitySupportedModes, createWorkbenchTask, listWorkbenchTasks, listWorkbenchTools, normalizeWorkbenchGuide, resolveWorkbenchResourceURL, type WorkbenchKind, type WorkbenchPromptPreset, type WorkbenchTool } from "@/services/api/workbench"; +import { resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources"; +import { formatCredits, requestCreditCost } from "@/constant/credits"; +import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; +import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { useUserStore } from "@/stores/use-user-store"; + +import { workbenchCategoryLabel, workbenchCategories } from "./workbench-catalog"; +import { missingWorkbenchInput, workbenchPromptRequired } from "./workbench-input"; +import "./workbench.css"; +import "./workbench-count-picker.css"; + +export type WorkbenchReference = { + id: string; + name: string; + kind: "image" | "video" | "audio"; + resourceId?: string; + previewUrl: string; + progress: number; + uploading: boolean; + error?: string; + role: "reference" | "first_frame" | "last_frame"; + slotKey?: string; +}; + +export function filterWorkbenchTools(tools: WorkbenchTool[], category: string, query: string) { + const normalized = query.trim().toLocaleLowerCase(); + return tools.filter((tool) => (category === "all" || (category === "recommended" ? tool.recommended || tool.categoryId === category : tool.categoryId === category)) && (!normalized || `${tool.name} ${tool.subtitle} ${tool.description} ${workbenchCategoryLabel(tool.categoryId)} ${(tool.searchKeywords || []).join(" ")}`.toLocaleLowerCase().includes(normalized))); +} + +function parseInput(inputJson?: string) { + if (!inputJson) return null; + try { + const parsed: unknown = JSON.parse(inputJson); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : null; + } catch { + return null; + } +} + +type WorkbenchTaskReferenceSnapshot = { + resourceId: string; + kind: WorkbenchReference["kind"]; + role?: WorkbenchReference["role"]; + slotKey?: string; +}; + +type WorkbenchTaskSnapshot = { + kind: WorkbenchKind; + toolId?: string; + presetId?: string; + parameters: Record; + references: WorkbenchTaskReferenceSnapshot[]; +}; + +export function workbenchTaskSnapshot(task: GenerationTask): WorkbenchTaskSnapshot | null { + const input = parseInput(task.inputJson); + if (!input) return null; + const metadata = input.metadata && typeof input.metadata === "object" ? input.metadata as Record : {}; + const metadataSnapshot = metadata.workbench && typeof metadata.workbench === "object" ? metadata.workbench as Record : {}; + const snapshot = input.workbench && typeof input.workbench === "object" ? input.workbench as Record : metadataSnapshot; + const kind = snapshot.kind || metadata.workbenchKind; + if (kind !== "image" && kind !== "video") return null; + const rawReferences = Array.isArray(snapshot.references) ? snapshot.references : []; + const references = rawReferences.flatMap((value: unknown): WorkbenchTaskReferenceSnapshot[] => { + if (!value || typeof value !== "object") return []; + const reference = value as Record; + const resourceId = typeof reference.resourceId === "string" ? reference.resourceId.trim() : ""; + const referenceKind = reference.kind; + if (!resourceId || (referenceKind !== "image" && referenceKind !== "video" && referenceKind !== "audio")) return []; + const role = reference.role === "first_frame" || reference.role === "last_frame" || reference.role === "reference" ? reference.role : undefined; + const slotKey = typeof reference.slotKey === "string" ? reference.slotKey : undefined; + return [{ resourceId, kind: referenceKind, ...(role ? { role } : {}), ...(slotKey ? { slotKey } : {}) }]; + }); + const rawParameters = snapshot.parameters && typeof snapshot.parameters === "object" ? snapshot.parameters : {}; + return { + kind, + ...(typeof snapshot.toolId === "string" ? { toolId: snapshot.toolId } : typeof metadata.workbenchToolId === "string" ? { toolId: metadata.workbenchToolId } : {}), + ...(typeof snapshot.presetId === "string" ? { presetId: snapshot.presetId } : typeof metadata.workbenchPresetId === "string" ? { presetId: metadata.workbenchPresetId } : {}), + parameters: rawParameters as Record, + references, + }; +} + +function taskPreview(task: GenerationTask) { + if (task.previewUrl) return task.previewUrl; + try { + const result = task.resultJson ? (JSON.parse(task.resultJson) as { images?: Array<{ url?: string; dataUrl?: string }>; video?: { url?: string; dataUrl?: string } }) : null; + return task.previewKind === "video" ? result?.video?.url || result?.video?.dataUrl || "" : result?.images?.[0]?.url || result?.images?.[0]?.dataUrl || ""; + } catch { + return ""; + } +} + +function elapsedLabel(task: GenerationTask) { + const start = task.startedAt ? Date.parse(task.startedAt) : Date.parse(task.createdAt); + const end = task.completedAt ? Date.parse(task.completedAt) : Date.now(); + if (!Number.isFinite(start) || !Number.isFinite(end)) return ""; + const seconds = Math.max(0, Math.floor((end - start) / 1000)); + return seconds < 60 ? `${seconds} 秒` : `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`; +} + +function displayTaskState(task: GenerationTask) { + if (task.status === "queued") return "排队中"; + if (task.status === "running") return task.stage || "生成中"; + if (task.status === "succeeded") return "已完成"; + if (task.status === "cancelled") return "已取消"; + return task.error || task.stage || "生成失败"; +} + +function revokeReferencePreview(reference: WorkbenchReference) { + if (reference.previewUrl.startsWith("blob:")) URL.revokeObjectURL(reference.previewUrl); +} + +export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { + const { message } = App.useApp(); + const config = useEffectiveConfig(); + const updateConfig = useConfigStore((state) => state.updateConfig); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const [searchParams, setSearchParams] = useSearchParams(); + const [tools, setTools] = useState([]); + const [directoryLoading, setDirectoryLoading] = useState(true); + const [directoryError, setDirectoryError] = useState(""); + const [toolLibraryOpen, setToolLibraryOpen] = useState(false); + const [toolSearch, setToolSearch] = useState(""); + const [toolCategory, setToolCategory] = useState("all"); + const [tool, setTool] = useState(null); + const [prompt, setPrompt] = useState(""); + const [references, setReferencesState] = useState([]); + const referencesRef = useRef([]); + const setReferences = (update: WorkbenchReference[] | ((current: WorkbenchReference[]) => WorkbenchReference[])) => { + const next = typeof update === "function" ? update(referencesRef.current) : update; + referencesRef.current = next; + setReferencesState(next); + }; + useEffect(() => () => referencesRef.current.forEach(revokeReferencePreview), []); + const [activeTask, setActiveTask] = useState(null); + const [tasks, setTasks] = useState([]); + const [tasksLoading, setTasksLoading] = useState(true); + const [detailTask, setDetailTask] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const detailRequestRef = useRef(0); + const [preset, setPreset] = useState(null); + const [presetPending, setPresetPending] = useState(null); + const [busy, setBusy] = useState(false); + const [taskBusyId, setTaskBusyId] = useState(""); + const [mobileTab, setMobileTab] = useState("generate"); + const [query, setQuery] = useState(""); + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); + const [estimateCredits, setEstimateCredits] = useState(null); + const [estimateEstimated, setEstimateEstimated] = useState(false); + const [estimateTone, setEstimateTone] = useState<"quote" | "local" | "usage" | "loading" | "unavailable">("unavailable"); + const fileRef = useRef(null); + const [uploadSlotKey, setUploadSlotKey] = useState(); + const preferencesHydrated = useCreationPreferencesStore((state) => state.hydrated); + const rememberImageSettings = useCreationPreferencesStore((state) => state.rememberImageSettings); + const rememberVideoSettings = useCreationPreferencesStore((state) => state.rememberVideoSettings); + const [settingsReady, setSettingsReady] = useState(false); + const [ratio, setRatio] = useState(kind === "image" ? "1:1" : "16:9"); + const [quality, setQuality] = useState("auto"); + const [count, setCount] = useState("1"); + const [seconds, setSeconds] = useState("6"); + const [videoQuality, setVideoQuality] = useState("720"); + const capability = kind; + const configuredModel = kind === "image" ? config.imageModel || config.model : config.videoModel || config.model; + // 与创作页同一份可选目录。系统渠道模型通常没有 logicalModelId,不能据此滤成空列表。 + const models = useMemo(() => selectableModelsByCapability(config, capability), [capability, config]); + const selectedModel = models.includes(configuredModel) ? configuredModel : models[0] || ""; + const profile = useMemo(() => modelCapabilityConfigFor(config, selectedModel), [config, selectedModel]); + const imageProfile = profile.image; + const videoProfile = profile.video; + const filteredTools = useMemo(() => filterWorkbenchTools(tools, toolCategory, toolSearch), [toolCategory, toolSearch, tools]); + const promptRequired = workbenchPromptRequired(tool); + const missingInput = missingWorkbenchInput(tool, references); + const activeTasks = useMemo(() => tasks.filter((task) => task.status === "queued" || task.status === "running"), [tasks]); + const historyTasks = useMemo(() => tasks.filter((task) => task.status !== "queued" && task.status !== "running"), [tasks]); + const selectedCount = Number(count) || 1; + const promptReferences = useMemo(() => references.map((reference) => ({ + id: reference.id, + nodeId: reference.id, + kind: reference.kind, + label: reference.name, + title: reference.name, + previewUrl: reference.previewUrl, + ...(reference.resourceId ? { storageKey: resourceStorageKey(reference.resourceId) } : {}), + active: true, + })), [references]); + const composerConfig = useMemo( + () => + kind === "video" && selectedModel + ? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality }) + : { ...config, model: selectedModel, imageModel: selectedModel, size: ratio, quality, count }, + [config, count, kind, quality, ratio, seconds, selectedModel, videoQuality], + ); + + useEffect(() => { + const controller = new AbortController(); + setDirectoryLoading(true); + setDirectoryError(""); + listWorkbenchTools(kind, controller.signal, false) + .then((directory) => { + setTools(directory.tools.filter((item) => item.kind === kind && item.enabled !== false && (!capabilitySupportedModes(item).length || capabilitySupportedModes(item).includes(kind))).sort((a, b) => a.sortOrder - b.sortOrder)); + }) + .catch((error) => { + if (!controller.signal.aborted) setDirectoryError(error instanceof Error ? error.message : "工具目录加载失败"); + }) + .finally(() => { + if (!controller.signal.aborted) setDirectoryLoading(false); + }); + return () => controller.abort(); + }, [kind]); + + useEffect(() => { + const toolId = searchParams.get("tool"); + if (!toolId || !tools.length) return; + const next = tools.find((item) => item.id === toolId); + if (next) setTool(next); + }, [searchParams, tools]); + + useEffect(() => { + if (!preferencesHydrated || settingsReady || !imageProfile || !videoProfile) return; + const saved = useCreationPreferencesStore.getState().preferences; + if (kind === "image") { + const normalized = normalizeImageValue(imageProfile, { + size: saved.image?.ratio || imageProfile.size.default, + quality: saved.image?.quality || imageProfile.quality.default, + count: saved.image?.count || "1", + }); + setRatio(normalized.size); + setQuality(normalized.quality); + setCount(normalized.count); + } else { + const normalized = normalizeVideoValue(videoProfile, { + seconds: saved.video?.seconds || String(videoProfile.duration.default), + ratio: saved.video?.ratio || videoProfile.defaultRatio, + resolution: saved.video?.videoQuality || videoProfile.defaultResolution, + }); + setSeconds(normalized.seconds); + setRatio(normalized.ratio); + setVideoQuality(normalized.resolution.replace(/p$/i, "")); + } + setSettingsReady(true); + }, [imageProfile, kind, preferencesHydrated, settingsReady, videoProfile]); + + useEffect(() => { + if (!settingsReady || kind !== "image" || !imageProfile) return; + const saved = useCreationPreferencesStore.getState().preferences.image; + const normalized = normalizeImageValue(imageProfile, { + size: saved?.ratio || imageProfile.size.default, + quality: saved?.quality || imageProfile.quality.default, + count: saved?.count || count, + }); + setRatio(normalized.size); + setQuality(normalized.quality); + setCount(normalized.count); + }, [imageProfile, kind, selectedModel, settingsReady]); + + useEffect(() => { + if (!settingsReady || kind !== "video" || !videoProfile) return; + const saved = useCreationPreferencesStore.getState().preferences.video; + const normalized = normalizeVideoValue(videoProfile, { + seconds: saved?.seconds || String(videoProfile.duration.default), + ratio: saved?.ratio || videoProfile.defaultRatio, + resolution: saved?.videoQuality || videoProfile.defaultResolution, + }); + setSeconds(normalized.seconds); + setRatio(normalized.ratio); + setVideoQuality(normalized.resolution.replace(/p$/i, "")); + }, [kind, selectedModel, settingsReady, videoProfile]); + + const quoteRequirements = useMemo( + () => ({ + capability, + input: { + textCount: prompt.trim() ? 1 : 0, + imageCount: references.filter((reference) => reference.kind === "image").length, + videoCount: references.filter((reference) => reference.kind === "video").length, + audioCount: references.filter((reference) => reference.kind === "audio").length, + characterCount: 0, + }, + ...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }), + }), + [capability, kind, prompt, quality, ratio, references, seconds, selectedCount, videoQuality], + ); + + useEffect(() => { + if (!creditsEnabled || !selectedModel) { + setEstimateCredits(null); + setEstimateTone("unavailable"); + return; + } + const priceChannel = resolveModelChannel(config, selectedModel); + const localCredits = requestCreditCost({ + channelMode: priceChannel.scope === "system" ? "remote" : "local", + modelCosts: priceChannel.modelCosts, + model: modelOptionName(selectedModel), + count: kind === "image" ? selectedCount : 1, + seconds: kind === "video" ? Number(seconds) || 1 : 1, + capability, + config: composerConfig, + requirements: quoteRequirements, + }); + const request = modelQuoteRequest(composerConfig, selectedModel, capability, quoteRequirements); + if (!request) { + setEstimateCredits(localCredits); + setEstimateEstimated(false); + setEstimateTone(localCredits === null ? "usage" : "local"); + return; + } + const controller = new AbortController(); + if (localCredits === null) setEstimateTone("loading"); + else { + setEstimateCredits(localCredits); + setEstimateTone("local"); + } + quoteModel(request, controller.signal) + .then(({ quote }) => { + setEstimateCredits(quote.amountMicrocredits / 1_000_000); + setEstimateEstimated(quote.estimated === true); + setEstimateTone("quote"); + }) + .catch(() => { + if (controller.signal.aborted) return; + setEstimateCredits(localCredits); + setEstimateEstimated(false); + setEstimateTone(localCredits === null ? "unavailable" : "local"); + }); + return () => controller.abort(); + }, [capability, composerConfig, config, creditsEnabled, kind, quoteRequirements, seconds, selectedCount, selectedModel]); + + const loadTasks = useCallback(async () => { + try { + const result = await listWorkbenchTasks(kind, 100); + setTasks(result); + return result; + } catch (error) { + message.error(error instanceof Error ? error.message : "任务列表加载失败"); + return []; + } finally { + setTasksLoading(false); + } + }, [kind, message]); + + useEffect(() => { + let stopped = false; + let timer = 0; + const poll = async () => { + const result = await loadTasks(); + if (stopped) return; + if (activeTask) { + const latest = result.find((item) => item.id === activeTask.id); + if (latest) { + setActiveTask(latest); + if (latest.status === "succeeded" || latest.status === "failed" || latest.status === "cancelled") setMobileTab("result"); + } + } + timer = window.setTimeout(() => void poll(), result.some((task) => task.status === "queued" || task.status === "running") ? 5000 : 30000); + }; + void poll(); + const onVisible = () => { + if (!document.hidden) { + window.clearTimeout(timer); + void poll(); + } + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + stopped = true; + window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [activeTask?.id, loadTasks]); + + const chooseTool = (next: WorkbenchTool | null) => { + references.forEach(revokeReferencePreview); + setReferences([]); + setUploadSlotKey(undefined); + setTool(next); + setPreset(null); + setToolLibraryOpen(false); + const params = new URLSearchParams(searchParams); + if (next) params.set("tool", next.id); + else params.delete("tool"); + params.delete("preset"); + setSearchParams(params, { replace: true }); + }; + + const choosePreset = (next: WorkbenchPromptPreset) => { + if (!prompt.trim()) { + setPrompt(next.promptTemplate); + setPreset(next); + return; + } + setPresetPending(next); + }; + + const applyPreset = (mode: "replace" | "append") => { + if (!presetPending) return; + setPrompt((current) => (mode === "append" ? `${current.trimEnd()}\n${presetPending.promptTemplate}` : presetPending.promptTemplate)); + setPreset(presetPending); + setPresetPending(null); + }; + + const updateImageSettings = (size: string, nextQuality?: string) => { + setRatio(size); + rememberImageSettings({ ratio: size }); + if (nextQuality) { + setQuality(nextQuality); + rememberImageSettings({ quality: nextQuality }); + } + }; + + const addFiles = async (files: FileList | File[], targetSlotKey = uploadSlotKey) => { + const list = Array.from(files); + const slot = tool?.inputContract?.slots.find((item) => item.key === targetSlotKey); + const allowedTypes = slot?.mediaTypes || (kind === "image" ? ["image"] : ["image", "video", "audio"]); + const accepted = list.filter((file) => allowedTypes.some((type) => file.type.startsWith(`${type}/`))); + if (accepted.length !== list.length) message.warning(`该输入槽位仅支持${allowedTypes.map((type) => type === "image" ? "图片" : type === "video" ? "视频" : "音频").join("、")}`); + const currentCount = references.filter((reference) => reference.slotKey === targetSlotKey).length; + const maxCount = slot?.maxCount || (kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)); + const picked = accepted.slice(0, Math.max(0, maxCount - currentCount)); + if (picked.length < accepted.length) message.warning("部分素材超过当前输入槽位数量上限,未添加"); + const pending: WorkbenchReference[] = picked.map((file) => ({ + id: crypto.randomUUID(), + name: file.name, + kind: (file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : "audio") as WorkbenchReference["kind"], + previewUrl: URL.createObjectURL(file), + progress: 0, + uploading: true, + role: "reference" as const, + ...(targetSlotKey ? { slotKey: targetSlotKey } : {}), + })); + setReferences((current) => [...current, ...pending]); + await Promise.all( + pending.map(async (reference, index) => { + const file = picked[index]; + try { + const resource = await uploadResourceFile(file, reference.kind, { fileName: file.name }, (uploaded, total) => { + setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, progress: total ? (uploaded / total) * 100 : 0 } : item))); + }); + setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, resourceId: resource.id, uploading: false, progress: 100 } : item))); + } catch (error) { + setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, uploading: false, error: error instanceof Error ? error.message : "上传失败" } : item))); + } + }), + ); + }; + + const removeReference = (reference: WorkbenchReference) => { + revokeReferencePreview(reference); + setReferences((current) => current.filter((item) => item.id !== reference.id)); + }; + + const submit = async () => { + const submittedPrompt = normalizeCanvasNodeMentionTokens(prompt.trim(), promptReferences); + if (tool?.inputContract?.promptRequired && !submittedPrompt) { + message.warning("当前工具需要填写提示词"); + return; + } + if (!tool && !submittedPrompt) { + message.warning("请填写提示词"); + return; + } + const missing = missingWorkbenchInput(tool, references); + if (missing) { + message.warning(`请补齐“${missing.name || "输入素材"}”`); + return; + } + const logicalModelId = logicalModelIDForConfig({ ...composerConfig, model: selectedModel }); + const channel = resolveModelChannel(config, selectedModel); + if (!selectedModel || !models.includes(selectedModel)) { + message.error(`请选择可用的${kind === "image" ? "图片" : "视频"}模型`); + return; + } + if (!isAiConfigReady(config, selectedModel)) { + message.error("当前模型配置不完整,请先完成模型设置"); + return; + } + if (references.some((reference) => reference.uploading || reference.error || !reference.resourceId)) { + message.warning("请等待参考素材上传完成或移除失败素材"); + return; + } + if (submittedPrompt.length > (kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000)) { + message.error("提示词超过当前模型支持的长度"); + return; + } + setBusy(true); + try { + const imageRequest = kind === "image" && imageProfile ? normalizeImageValue(imageProfile, { size: ratio, quality, count, transparentBackground: config.transparentBackground }) : null; + const videoRequest = kind === "video" && videoProfile ? normalizeVideoValue(videoProfile, { seconds, ratio, resolution: videoQuality }) : null; + const parameters = imageRequest + ? { size: imageRequest.size, quality: imageRequest.quality, count: Number(imageRequest.count) } + : { seconds: Number(videoRequest?.seconds || seconds), ratio: videoRequest?.ratio || ratio, resolution: videoRequest?.resolution || videoQuality, generateAudio: composerConfig.videoGenerateAudio === "true", watermark: composerConfig.videoWatermark === "true" }; + const generationConfig = { + ...composerConfig, + model: selectedModel, + ...(imageRequest ? { imageModel: selectedModel, size: imageRequest.size, quality: imageRequest.quality, count: imageRequest.count } : {}), + ...(videoRequest ? { videoModel: selectedModel, size: videoRequest.ratio, videoSeconds: videoRequest.seconds, vquality: videoRequest.resolution.replace(/p$/i, "") } : {}), + }; + const readyReferences = references.filter((reference) => reference.resourceId); + const referenceSnapshot = readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })); + const createdTask = logicalModelId || channel?.scope === "system" + ? await createWorkbenchTask({ + kind, + ...(tool ? { toolId: tool.id } : {}), + ...(tool && preset ? { presetId: preset.id } : {}), + prompt: submittedPrompt, + model: modelOptionName(selectedModel), + ...(logicalModelId ? { logicalModelId } : { channelId: channel.id }), + parameters, + references: readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), + }) + : await submitBackendGenerationTask({ + mode: kind, + prompt: submittedPrompt, + config: generationConfig, + referenceImages: readyReferences.filter((reference) => reference.kind === "image").map((reference) => ({ id: reference.id, name: reference.name, type: "image/*", dataUrl: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), + referenceVideos: readyReferences.filter((reference) => reference.kind === "video").map((reference) => ({ id: reference.id, name: reference.name, type: "video/*", url: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), + referenceAudios: readyReferences.filter((reference) => reference.kind === "audio").map((reference) => ({ id: reference.id, name: reference.name, type: "audio/*", url: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), + metadata: { + source: "workbench", + workbenchKind: kind, + ...(tool ? { workbenchToolId: tool.id, workbenchToolName: tool.name } : {}), + ...(tool && preset ? { workbenchPresetId: preset.id } : {}), + workbench: { kind, ...(tool ? { toolId: tool.id } : {}), ...(tool && preset ? { presetId: preset.id } : {}), parameters, references: referenceSnapshot }, + }, + }); + const task = await queryGenerationTask(createdTask.id); + setActiveTask(task); + setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]); + setMobileTab("result"); + message.success("生成任务已提交"); + } catch (error) { + message.error(error instanceof Error ? error.message : "任务提交失败"); + } finally { + setBusy(false); + } + }; + + const cancelTask = async (task: GenerationTask) => { + setTaskBusyId(task.id); + try { + const next = await cancelGenerationTask(task.id); + setTasks((current) => current.map((item) => (item.id === task.id ? next : item))); + if (activeTask?.id === task.id) setActiveTask(next); + } catch (error) { + message.error(error instanceof Error ? error.message : "取消任务失败"); + } finally { + setTaskBusyId(""); + } + }; + + const restoreTaskToComposer = async (task: GenerationTask, notify = true) => { + const snapshot = workbenchTaskSnapshot(task); + if (!snapshot || snapshot.kind !== kind) { + if (notify) message.warning("该任务没有可恢复的工作台参数"); + return false; + } + const restoredTool = snapshot.toolId ? tools.find((item) => item.id === snapshot.toolId) || null : null; + const restoredPreset = restoredTool && snapshot.presetId ? restoredTool.presets.find((item) => item.id === snapshot.presetId) || null : null; + const parameters = snapshot.parameters; + if (kind === "image") { + const restoredRatio = typeof parameters.size === "string" ? parameters.size : ratio; + const restoredQuality = typeof parameters.quality === "string" ? parameters.quality : quality; + const restoredCount = parameters.count != null ? String(parameters.count) : count; + setRatio(restoredRatio); + setQuality(restoredQuality); + setCount(restoredCount); + rememberImageSettings({ ratio: restoredRatio, quality: restoredQuality, count: restoredCount }); + } else { + const restoredSeconds = parameters.seconds != null ? String(parameters.seconds) : seconds; + const restoredRatio = typeof parameters.ratio === "string" ? parameters.ratio : ratio; + const restoredQuality = typeof parameters.resolution === "string" ? parameters.resolution.replace(/p$/i, "") : videoQuality; + setSeconds(restoredSeconds); + setRatio(restoredRatio); + setVideoQuality(restoredQuality); + rememberVideoSettings({ seconds: restoredSeconds, ratio: restoredRatio, videoQuality: restoredQuality }); + if (typeof parameters.generateAudio === "boolean") updateConfig("videoGenerateAudio", String(parameters.generateAudio)); + if (typeof parameters.watermark === "boolean") updateConfig("videoWatermark", String(parameters.watermark)); + } + const restoredModel = task.model ? models.find((item) => item === task.model || modelOptionName(item) === task.model) : undefined; + if (restoredModel) { + updateConfig(kind === "image" ? "imageModel" : "videoModel", restoredModel); + updateConfig("model", restoredModel); + } + setTool(restoredTool); + setPreset(restoredPreset); + setPrompt(task.prompt || ""); + setUploadSlotKey(undefined); + referencesRef.current.forEach(revokeReferencePreview); + setReferences([]); + const restoredReferences = await Promise.all(snapshot.references.map(async (reference, index): Promise => ({ + id: crypto.randomUUID(), + name: `${reference.kind === "image" ? "图片" : reference.kind === "video" ? "视频" : "音频"} ${index + 1}`, + kind: reference.kind, + resourceId: reference.resourceId, + previewUrl: await resolveWorkbenchResourceURL(reference.resourceId, "original") || "", + progress: 100, + uploading: false, + role: reference.role || "reference", + ...(reference.slotKey ? { slotKey: reference.slotKey } : {}), + }))); + setReferences(restoredReferences); + const params = new URLSearchParams(searchParams); + if (restoredTool) params.set("tool", restoredTool.id); + else params.delete("tool"); + if (restoredPreset) params.set("preset", restoredPreset.id); + else params.delete("preset"); + setSearchParams(params, { replace: true }); + setActiveTask(task); + setMobileTab("generate"); + setDetailTask(null); + if (notify) message.success("任务配置已载入编辑器"); + return true; + }; + + const retryTask = async (task: GenerationTask) => { + setTaskBusyId(task.id); + try { + let restoreSource = task; + if (!restoreSource.inputJson) { + try { + restoreSource = await queryGenerationTask(task.id); + } catch { + // The retry response also contains the safe restore snapshot. + } + } + const restored = await restoreTaskToComposer(restoreSource, false); + const next = await retryGenerationTask(task.id); + if (!restored) await restoreTaskToComposer(next, false); + setTasks((current) => [next, ...current.filter((item) => item.id !== next.id)]); + setActiveTask(next); + message.success("任务已重新提交"); + } catch (error) { + message.error(error instanceof Error ? error.message : "重试任务失败"); + } finally { + setTaskBusyId(""); + } + }; + + const openDetail = async (task: GenerationTask) => { + const requestId = ++detailRequestRef.current; + setDetailTask(task); + setDetailLoading(true); + try { + const detail = await queryGenerationTask(task.id); + if (requestId === detailRequestRef.current) setDetailTask(detail); + } catch (error) { + if (requestId === detailRequestRef.current) message.error(error instanceof Error ? error.message : "任务详情加载失败"); + } finally { + if (requestId === detailRequestRef.current) setDetailLoading(false); + } + }; + + const estimateLabel = !creditsEnabled + ? "" + : estimateCredits !== null + ? `${estimateEstimated || estimateTone !== "quote" ? "预估" : "预计"} ${estimateCredits.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分` + : estimateTone === "loading" + ? "正在计算积分" + : estimateTone === "usage" + ? "按实际用量结算" + : "报价暂不可用"; + const resultTask = activeTask || tasks.find((task) => task.status === "succeeded") || null; + const resultUrl = resultTask ? taskPreview(resultTask) : ""; + const settingsPanel = kind === "image" && imageProfile ? ( +
+ + {imageProfile.quality.supported && !imageResolutionUsesQuality(imageProfile) ? ( +
+ ); + + return ( + +
+
+
+ setMobileTab(String(value))} + options={[ + { label: "生成", value: "generate" }, + { label: "结果", value: "result" }, + { label: "任务", value: "tasks" }, + ]} + /> +
+ +
+
+
+ {kind === "image" ? "图片生成" : "视频生成"} + + + +
+
+
+ void submit()} + sendOnEnter={false} + style={{ color: "var(--wb-text)" }} + placeholder={kind === "image" ? "写下要生成的画面:主体、场景、构图、光线、风格" : "写下镜头里发生什么:主体、运动、光线、节奏、转场"} + maxLength={kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000} + /> +
+ + {prompt.length} / {kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000} + + ⌘ / Ctrl + Enter +
+
+
+ + 参考素材 {references.length} / {kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)} + +
+ + + + + + +
+
+ {tool?.inputContract?.slots.length ? ( +
+ {tool.inputContract.slots.map((slot) => { + const slotReferences = references.filter((reference) => reference.slotKey === slot.key); + const mediaLabel = slot.mediaTypes.map((type) => type === "image" ? "图片" : type === "video" ? "视频" : "音频").join(" / "); + const openSlotPicker = () => { setUploadSlotKey(slot.key); fileRef.current?.click(); }; + return ( +
+
+
+
+ {slot.name || "参考素材"} + {slot.required ? "必填" : "可选"} +
+
+ {slot.minCount}-{slot.maxCount} 个 + {mediaLabel} +
+
+ {slotReferences.length ? ( + + ) : null} +
+
+ {slotReferences.map((reference) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)} + {!slotReferences.length ? ( + + ) : null} +
+
+ ); + })} +
+ ) : ( +
+ {references.map((reference) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)} + {!references.length || (kind === "video" && references.length < 5) ? : null} +
+ )} + slot.key === uploadSlotKey)?.mediaTypes.map((type) => `${type}/*`).join(",") : kind === "image" ? "image/*" : "image/*,video/*,audio/*"} + multiple + onChange={(event) => { + if (event.target.files) void addFiles(event.target.files, uploadSlotKey); + event.currentTarget.value = ""; + }} + /> +
+
+ {kind === "image" ? "图片参数" : "视频参数"} + {kind === "image" ? "尺寸、质量和数量" : "时长、画幅、清晰度和音频"} +
+ {selectedModel ? settingsPanel :

先选择模型,再调整尺寸、数量或时长。

} +
+
+
+ {models.length ? ( + reference.kind === "image").length, videoCount: references.filter((reference) => reference.kind === "video").length, audioCount: references.filter((reference) => reference.kind === "audio").length, characterCount: 0 }, ...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }) }} + variant="creation" + fullWidth + className="creation-model-picker workbench-model-picker" + showSelectedPrice={false} + showOptionPrices + onChange={(value) => { + if (kind === "image") updateConfig("imageModel", value); + else updateConfig("videoModel", value); + updateConfig("model", value); + }} + placeholder={`选择${kind === "image" ? "图片" : "视频"}模型`} + /> + ) : ( +
+ 暂无可用的{kind === "image" ? "图片" : "视频"}模型 + 模型设置 +
+ )} +
+ +
+
+
+
+ +
+
+
+
+

生成结果

+ {resultTask ? displayTaskState(resultTask) : "作品会在这里呈现"} +
+ {resultTask?.status === "running" || resultTask?.status === "queued" ? : null} +
+ {!resultTask ? ( +
+
+ {kind === "image" ? : +
+ {kind === "image" ? "图片会显示在这里" : "视频会显示在这里"} + {kind === "image" ? "写好画面后生成,结果保持原始比例。" : "写好镜头后生成,结果以 16:9 画幅预览。"} +
+ ) : resultTask.status === "queued" || resultTask.status === "running" ? ( +
+
+ + + +
+ {displayTaskState(resultTask)} + 任务已安全进入队列,完成后会自动显示结果 + +
+ ) : resultTask.status === "succeeded" && resultUrl ? ( +
+ {resultTask.previewKind === "video" || kind === "video" ? ( +
+ ) : ( +
+ {displayTaskState(resultTask)} + +
+ )} +
+
+
+
+

{tool?.name || "工具说明"}

+ 创作指南与效果预览 +
+
+ {tool ? ( + <> +

{tool.description || tool.subtitle}

+ {normalizeWorkbenchGuide(tool.guide) ? ( +
+ {normalizeWorkbenchGuide(tool.guide) + .split("\n") + .filter(Boolean) + .map((line, index) => ( +

{line}

+ ))} +
+ ) : ( +
选择适合的模型和参考素材,写下你希望呈现的画面细节。
+ )} + {tool.inputHint ?

{tool.inputHint}

: null} + {tool.inputContract?.slots.length ?
输入素材{tool.inputContract.slots.map((slot) => {slot.name} · {slot.required ? "必填" : "可选"} · 最多 {slot.maxCount} 个)}
: null} + {tool.resultExample?.imageUrls?.length || tool.resultExample?.videoUrl ? ( +
+ {tool.resultExample.title || "效果参考"} + {tool.resultExample.imageUrls?.slice(0, 3).map((url) => 工具效果参考)} + {tool.resultExample.videoUrl ? : null} +
+ ) : null} + + ) : ( +
选定工具后,这里会展示创作说明和示例效果。
+ )} +
+
+ + +
+
+ + { + detailRequestRef.current += 1; + setDetailLoading(false); + setDetailTask(null); + }} + footer={null} + width={960} + className="workbench-detail-modal" + > + {detailTask ? ( +
+
+ {taskPreview(detailTask) ? ( + detailTask.previewKind === "video" || kind === "video" ? ( +
+
+ {displayTaskState(detailTask)} +

{detailTask.model || "生成任务"}

+ +

{detailTask.prompt}

+ +
{detailLoading ? "正在加载任务参数…" : JSON.stringify(workbenchTaskSnapshot(detailTask)?.parameters || {}, null, 2)}
+
+
+
耗时
+
{elapsedLabel(detailTask)}
+
+
+
积分
+
{detailTask.billing ? `${formatCredits(detailTask.billing.amountMicrocredits)} · ${detailTask.billing.status}` : "--"}
+
+
+
创建时间
+
{new Date(detailTask.createdAt).toLocaleString()}
+
+
+
+ + {detailTask.status === "failed" || detailTask.status === "cancelled" ? : null} +
+
+
+ ) : null} +
+ + setPresetPending(null)} + footer={[ + , + , + , + ]} + > + {presetPending ?

将应用“{presetPending.name}”提示词。你可以替换当前内容,也可以追加到末尾。

: null} +
+
+ ); +} + +function CountPicker({ value, max, onChange }: { value: string; max: number; onChange: (value: string) => void }) { + const limit = Math.min(10, Math.max(1, max || 1)); + const current = Math.min(limit, Math.max(1, Math.floor(Number(value) || 1))); + return ( +
+ 生成数量 +
+ {Array.from({ length: limit }, (_, index) => index + 1).map((item) => ( + + ))} + onChange(String(Math.min(limit, Math.max(1, Number(next) || 1))))} /> +
+
+ ); +} + +function ParameterChips({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) { + if (options.length > 7) { + return