feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置
This commit is contained in:
1 parent
4c1d2b4012
commit
1f3f2c6020
69 files changed
+7108
-87
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user