feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填
选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。 补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。 验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。 Refs #547 Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
1 parent
be5f2733b6
commit
e93bf3ca39
66 files changed
+6127
-97
No files matched your search
@@ -0,0 +1,56 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
// ToolWithFavorite 列表查询结果:工具 + 当前用户收藏状态。
|
||||
type ToolWithFavorite struct {
|
||||
Tool model.Tool
|
||||
Favorited bool
|
||||
FavoritedAt *time.Time
|
||||
}
|
||||
|
||||
// Repository 定义 tools 域需要的持久化接口,由 repository.Repository 实现。
|
||||
type Repository interface {
|
||||
UpsertBuiltinTools(tools []model.Tool) error
|
||||
ListTools(userID string, req ToolListRequest) ([]ToolWithFavorite, int64, error)
|
||||
ToolForUser(userID string, toolID int64) (model.Tool, error)
|
||||
ToolFavorited(userID string, toolID int64) (model.Tool, *time.Time, error)
|
||||
AddToolFavorite(userID string, toolID int64) error
|
||||
RemoveToolFavorite(userID string, toolID int64) error
|
||||
CreateTool(tool *model.Tool) (*model.Tool, error)
|
||||
DeleteUserTool(userID string, toolID int64) error
|
||||
}
|
||||
|
||||
var labelEnPattern = regexp.MustCompile(`[^a-zA-Z0-9_]+`)
|
||||
|
||||
// sanitizeLabelEn 从中文名称生成英文标识:取拼音不可行时用时间戳保证唯一性由数据库自增 ID 兜底。
|
||||
func sanitizeLabelEn(label string) string {
|
||||
normalized := labelEnPattern.ReplaceAllString(strings.ToLower(label), "_")
|
||||
normalized = strings.Trim(normalized, "_")
|
||||
if normalized == "" {
|
||||
normalized = "custom_tool"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func marshalExtraInfo(items []string) ([]byte, error) {
|
||||
return json.Marshal(items)
|
||||
}
|
||||
|
||||
func decodeExtraInfo(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{}
|
||||
}
|
||||
var items []string
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
)
|
||||
|
||||
var toolMentionPattern = regexp.MustCompile(`@\[tool:(style|motion|nine_grid|effect):(\d+):[^:\]]+:[^\]]+\]`)
|
||||
|
||||
// ResolveToolMentionTokens validates every tool before expanding it. Labels and
|
||||
// icons are presentation only; neither grants access to a private tool.
|
||||
func (s *Service) ResolveToolMentionTokens(userID, mode, prompt string) (string, error) {
|
||||
if !strings.Contains(prompt, "@[tool:") {
|
||||
return prompt, nil
|
||||
}
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return "", kernel.Unauthorized("请先登录")
|
||||
}
|
||||
if strings.Contains(toolMentionPattern.ReplaceAllString(prompt, ""), "@[tool:") {
|
||||
return "", kernel.BadAuthRequest("工具标签无效,请重新选择工具")
|
||||
}
|
||||
prompts := make(map[string]string)
|
||||
for _, match := range toolMentionPattern.FindAllStringSubmatch(prompt, -1) {
|
||||
if _, ok := prompts[match[0]]; ok {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(match[2], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return "", kernel.BadAuthRequest("工具 ID 无效")
|
||||
}
|
||||
tool, err := s.repo.ToolForUser(userID, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !tool.Enabled || tool.Type != match[1] {
|
||||
return "", kernel.BadAuthRequest("工具已停用或类型不匹配")
|
||||
}
|
||||
imageTool := tool.Type == ToolTypeStyle || tool.Type == ToolTypeNineGrid
|
||||
if (imageTool && mode != "image") || (!imageTool && mode != "video") {
|
||||
return "", kernel.BadAuthRequest("工具不适用于当前生成类型")
|
||||
}
|
||||
if strings.TrimSpace(tool.Prompt) == "" || strings.Contains(tool.Prompt, "@[tool:") {
|
||||
return "", kernel.BadAuthRequest("工具提示词为空或包含嵌套工具标签")
|
||||
}
|
||||
prompts[match[0]] = tool.Prompt
|
||||
}
|
||||
return toolMentionPattern.ReplaceAllStringFunc(prompt, func(token string) string { return prompts[token] }), nil
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolSourceBuiltin = "builtin"
|
||||
ToolSourceUser = "user"
|
||||
|
||||
ToolVisibilityPublic = "public"
|
||||
ToolVisibilityPrivate = "private"
|
||||
|
||||
// 查询范围
|
||||
ToolScopePublic = "public"
|
||||
ToolScopeFavorites = "favorites"
|
||||
ToolScopeCustom = "custom"
|
||||
|
||||
toolMaxPageSize = 80
|
||||
toolMaxPromptLength = 8000
|
||||
)
|
||||
|
||||
var validToolTypes = map[string]struct{}{
|
||||
ToolTypeStyle: {},
|
||||
ToolTypeMotion: {},
|
||||
ToolTypeNineGrid: {},
|
||||
ToolTypeEffect: {},
|
||||
}
|
||||
|
||||
// ToolListRequest 工具列表查询参数。
|
||||
type ToolListRequest struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Scope string `json:"scope"` // public | favorites | custom
|
||||
Type string `json:"type"` // style | motion | nine_grid | effect
|
||||
Tag string `json:"tag"`
|
||||
Search string `json:"search"`
|
||||
}
|
||||
|
||||
// ToolItem 工具列表项;ExtraInfo 为样本图路径数组。
|
||||
type ToolItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
LabelEn string `json:"labelEn"`
|
||||
Label string `json:"label"`
|
||||
Desc string `json:"desc"`
|
||||
Tag string `json:"tag"`
|
||||
Cover string `json:"cover"`
|
||||
ExtraInfo []string `json:"extraInfo"`
|
||||
Prompt string `json:"prompt"`
|
||||
Ratio string `json:"ratio"`
|
||||
MediaURL string `json:"mediaUrl"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Source string `json:"source"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Visibility string `json:"visibility"`
|
||||
SortWeight int `json:"sortWeight"`
|
||||
Favorited bool `json:"favorited"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ToolSummary 工具列表摘要项;不含 prompt、extraInfo 等大字段,详情/收藏结果才返回完整 ToolItem。
|
||||
type ToolSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
LabelEn string `json:"labelEn"`
|
||||
Label string `json:"label"`
|
||||
Desc string `json:"desc"`
|
||||
Tag string `json:"tag"`
|
||||
Cover string `json:"cover"`
|
||||
Ratio string `json:"ratio"`
|
||||
MediaURL string `json:"mediaUrl"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Source string `json:"source"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Visibility string `json:"visibility"`
|
||||
SortWeight int `json:"sortWeight"`
|
||||
Favorited bool `json:"favorited"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ToolList 分页结果。
|
||||
type ToolList struct {
|
||||
Tools []ToolSummary `json:"tools"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
// ToolMutationRequest 新增/更新自定义工具的请求体。
|
||||
type ToolMutationRequest struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Desc string `json:"desc"`
|
||||
Tag string `json:"tag"`
|
||||
Cover string `json:"cover"`
|
||||
ExtraInfo []string `json:"extraInfo"`
|
||||
Prompt string `json:"prompt"`
|
||||
Ratio string `json:"ratio"`
|
||||
MediaURL string `json:"mediaUrl"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
// Service 工具域服务。
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func New(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// List 按范围分页查询工具列表。
|
||||
func (s *Service) List(userID string, req ToolListRequest) (*ToolList, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return nil, kernel.Unauthorized("请先登录")
|
||||
}
|
||||
if err := normalizeToolListRequest(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, total, err := s.repo.ListTools(userID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := make([]ToolSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
tools = append(tools, buildToolSummary(item.Tool, item.Favorited))
|
||||
}
|
||||
return &ToolList{
|
||||
Tools: tools,
|
||||
TotalCount: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
HasMore: int64(req.Page*req.PageSize) < total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Detail 返回单个工具详情(含提示词),供工具栏快捷调用。
|
||||
func (s *Service) Detail(userID string, toolID int64) (*ToolItem, error) {
|
||||
if userID == "" {
|
||||
return nil, kernel.Unauthorized("请先登录")
|
||||
}
|
||||
if toolID <= 0 {
|
||||
return nil, kernel.BadAuthRequest("工具 ID 无效")
|
||||
}
|
||||
tool, err := s.repo.ToolForUser(userID, toolID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildToolItemPtr(tool, false, nil), nil
|
||||
}
|
||||
|
||||
// SetFavorite 添加/取消收藏;同一用户对同一工具仅一条记录。
|
||||
func (s *Service) SetFavorite(userID string, toolID int64, favorite bool) (*ToolItem, error) {
|
||||
if userID == "" {
|
||||
return nil, kernel.Unauthorized("请先登录")
|
||||
}
|
||||
if toolID <= 0 {
|
||||
return nil, kernel.BadAuthRequest("工具 ID 无效")
|
||||
}
|
||||
if _, err := s.repo.ToolForUser(userID, toolID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if favorite {
|
||||
if err := s.repo.AddToolFavorite(userID, toolID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := s.repo.RemoveToolFavorite(userID, toolID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
updated, favoritedAt, err := s.repo.ToolFavorited(userID, toolID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildToolItemPtr(updated, true, favoritedAt), nil
|
||||
}
|
||||
|
||||
// Create 创建用户自定义工具。
|
||||
func (s *Service) Create(userID string, req ToolMutationRequest) (*ToolItem, error) {
|
||||
if userID == "" {
|
||||
return nil, kernel.Unauthorized("请先登录")
|
||||
}
|
||||
normalized, err := normalizeToolMutationRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
tool := model.Tool{
|
||||
Type: normalized.Type,
|
||||
LabelEn: normalized.LabelEn,
|
||||
Label: normalized.Label,
|
||||
Desc: normalized.Desc,
|
||||
Tag: normalized.Tag,
|
||||
Cover: normalized.Cover,
|
||||
ExtraInfoJSON: normalized.ExtraInfoJSON,
|
||||
Prompt: normalized.Prompt,
|
||||
Ratio: normalized.Ratio,
|
||||
MediaURL: normalized.MediaURL,
|
||||
OwnerID: userID,
|
||||
Source: ToolSourceUser,
|
||||
Enabled: true,
|
||||
Visibility: normalized.Visibility,
|
||||
SortWeight: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
created, err := s.repo.CreateTool(&tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := buildToolItem(*created, false, nil)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// Delete 删除用户自定义工具;内置工具不可删除,同步清理收藏记录。
|
||||
func (s *Service) Delete(userID string, toolID int64) error {
|
||||
if userID == "" {
|
||||
return kernel.Unauthorized("请先登录")
|
||||
}
|
||||
if toolID <= 0 {
|
||||
return kernel.BadAuthRequest("工具 ID 无效")
|
||||
}
|
||||
return s.repo.DeleteUserTool(userID, toolID)
|
||||
}
|
||||
|
||||
func normalizeToolListRequest(req *ToolListRequest) error {
|
||||
if req.Scope == "" {
|
||||
req.Scope = ToolScopePublic
|
||||
}
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > toolMaxPageSize {
|
||||
req.PageSize = toolMaxPageSize
|
||||
}
|
||||
switch req.Scope {
|
||||
case ToolScopePublic, ToolScopeFavorites, ToolScopeCustom:
|
||||
default:
|
||||
return kernel.BadAuthRequest(fmt.Sprintf("不支持的范围: %s", req.Scope))
|
||||
}
|
||||
if req.Type != "" {
|
||||
if _, ok := validToolTypes[req.Type]; !ok {
|
||||
return kernel.BadAuthRequest(fmt.Sprintf("不支持的工具类型: %s", req.Type))
|
||||
}
|
||||
}
|
||||
req.Search = strings.TrimSpace(req.Search)
|
||||
req.Tag = strings.TrimSpace(req.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeToolMutationRequest(req ToolMutationRequest) (*model.Tool, error) {
|
||||
toolType := strings.TrimSpace(req.Type)
|
||||
if _, ok := validToolTypes[toolType]; !ok {
|
||||
return nil, kernel.BadAuthRequest("工具类型无效,仅支持 style、motion、nine_grid、effect")
|
||||
}
|
||||
label := strings.TrimSpace(req.Label)
|
||||
if label == "" {
|
||||
return nil, kernel.BadAuthRequest("工具名称不能为空")
|
||||
}
|
||||
if len(label) > 120 {
|
||||
return nil, kernel.BadAuthRequest("工具名称过长")
|
||||
}
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
return nil, kernel.BadAuthRequest("工具提示词不能为空")
|
||||
}
|
||||
if strings.Contains(prompt, "@[tool:") {
|
||||
return nil, kernel.BadAuthRequest("不支持嵌套工具标签")
|
||||
}
|
||||
if len(prompt) > toolMaxPromptLength {
|
||||
return nil, kernel.BadAuthRequest("工具提示词过长")
|
||||
}
|
||||
desc := strings.TrimSpace(req.Desc)
|
||||
if len(desc) > 500 {
|
||||
return nil, kernel.BadAuthRequest("工具描述过长")
|
||||
}
|
||||
tag := strings.TrimSpace(req.Tag)
|
||||
if len(tag) > 64 {
|
||||
return nil, kernel.BadAuthRequest("标签过长")
|
||||
}
|
||||
visibility := strings.TrimSpace(req.Visibility)
|
||||
if visibility == "" {
|
||||
visibility = ToolVisibilityPrivate
|
||||
}
|
||||
if visibility != ToolVisibilityPublic && visibility != ToolVisibilityPrivate {
|
||||
return nil, kernel.BadAuthRequest("可见性仅支持 public 或 private")
|
||||
}
|
||||
cover := strings.TrimSpace(req.Cover)
|
||||
if len(cover) > 500 {
|
||||
return nil, kernel.BadAuthRequest("封面路径过长")
|
||||
}
|
||||
mediaURL := strings.TrimSpace(req.MediaURL)
|
||||
if len(mediaURL) > 500 {
|
||||
return nil, kernel.BadAuthRequest("媒体路径过长")
|
||||
}
|
||||
ratio := strings.TrimSpace(req.Ratio)
|
||||
if len(ratio) > 32 {
|
||||
return nil, kernel.BadAuthRequest("比例参数过长")
|
||||
}
|
||||
extraInfo := make([]string, 0, len(req.ExtraInfo))
|
||||
for _, item := range req.ExtraInfo {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if len(trimmed) > 500 {
|
||||
return nil, kernel.BadAuthRequest("扩展信息路径过长")
|
||||
}
|
||||
extraInfo = append(extraInfo, trimmed)
|
||||
}
|
||||
extraInfoJSON := ""
|
||||
if len(extraInfo) > 0 {
|
||||
data, err := marshalExtraInfo(extraInfo)
|
||||
if err != nil {
|
||||
return nil, kernel.BadAuthRequest("扩展信息格式无效")
|
||||
}
|
||||
extraInfoJSON = string(data)
|
||||
}
|
||||
labelEn := sanitizeLabelEn(label)
|
||||
return &model.Tool{
|
||||
Type: toolType,
|
||||
LabelEn: labelEn,
|
||||
Label: label,
|
||||
Desc: desc,
|
||||
Tag: tag,
|
||||
Cover: cover,
|
||||
ExtraInfoJSON: extraInfoJSON,
|
||||
Prompt: prompt,
|
||||
Ratio: ratio,
|
||||
MediaURL: mediaURL,
|
||||
Visibility: visibility,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildToolSummary(tool model.Tool, favorited bool) ToolSummary {
|
||||
return ToolSummary{
|
||||
ID: tool.ID,
|
||||
Type: tool.Type,
|
||||
LabelEn: tool.LabelEn,
|
||||
Label: tool.Label,
|
||||
Desc: tool.Desc,
|
||||
Tag: tool.Tag,
|
||||
Cover: tool.Cover,
|
||||
Ratio: tool.Ratio,
|
||||
MediaURL: tool.MediaURL,
|
||||
OwnerID: tool.OwnerID,
|
||||
Source: tool.Source,
|
||||
Enabled: tool.Enabled,
|
||||
Visibility: tool.Visibility,
|
||||
SortWeight: tool.SortWeight,
|
||||
Favorited: favorited,
|
||||
CreatedAt: tool.CreatedAt,
|
||||
UpdatedAt: tool.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func buildToolItem(tool model.Tool, favorited bool, favoritedAt *time.Time) ToolItem {
|
||||
return ToolItem{
|
||||
ID: tool.ID,
|
||||
Type: tool.Type,
|
||||
LabelEn: tool.LabelEn,
|
||||
Label: tool.Label,
|
||||
Desc: tool.Desc,
|
||||
Tag: tool.Tag,
|
||||
Cover: tool.Cover,
|
||||
ExtraInfo: decodeExtraInfo(tool.ExtraInfoJSON),
|
||||
Prompt: tool.Prompt,
|
||||
Ratio: tool.Ratio,
|
||||
MediaURL: tool.MediaURL,
|
||||
OwnerID: tool.OwnerID,
|
||||
Source: tool.Source,
|
||||
Enabled: tool.Enabled,
|
||||
Visibility: tool.Visibility,
|
||||
SortWeight: tool.SortWeight,
|
||||
Favorited: favorited,
|
||||
CreatedAt: tool.CreatedAt,
|
||||
UpdatedAt: tool.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func buildToolItemPtr(tool model.Tool, favorited bool, favoritedAt *time.Time) *ToolItem {
|
||||
item := buildToolItem(tool, favorited, favoritedAt)
|
||||
return &item
|
||||
}
|
||||
|
||||
// IsToolNotFound 判断是否为工具不存在(含他人私有工具不可见)。
|
||||
func IsToolNotFound(err error) bool {
|
||||
var appErr *kernel.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Status == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"infinite-canvas/backend/internal/tools"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolsVisibilityTokensSeedAndReferences(t *testing.T) {
|
||||
db, err := database.Open(database.Config{Driver: "sqlite", DSN: filepath.Join(t.TempDir(), "tools.db")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
if err = database.MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := repository.New(db)
|
||||
svc := tools.New(repo)
|
||||
for range 2 {
|
||||
if err := tools.EnsureBuiltinTools(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
list, err := svc.List("alice", tools.ToolListRequest{PageSize: 80})
|
||||
if err != nil || list.TotalCount != 87 || !list.HasMore {
|
||||
t.Fatalf("seed/pagination: %+v %v", list, err)
|
||||
}
|
||||
custom, err := svc.Create("alice", tools.ToolMutationRequest{Type: "style", Label: "private", Prompt: "private style"})
|
||||
if err != nil || custom.ID <= 87 {
|
||||
t.Fatalf("custom: %+v %v", custom, err)
|
||||
}
|
||||
if _, err := svc.Detail("bob", custom.ID); err == nil {
|
||||
t.Fatal("private tool leaked")
|
||||
}
|
||||
if _, err := svc.SetFavorite("bob", custom.ID, true); err == nil {
|
||||
t.Fatal("private favorite accepted")
|
||||
}
|
||||
if err := svc.Delete("bob", custom.ID); err == nil {
|
||||
t.Fatal("cross-user delete accepted")
|
||||
}
|
||||
if _, err := svc.SetFavorite("alice", custom.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.SetFavorite("alice", custom.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, scope := range []string{"", "public", "favorites", "custom"} {
|
||||
got, err := svc.List("bob", tools.ToolListRequest{Scope: scope, Search: "private"})
|
||||
if err != nil || got.TotalCount != 0 {
|
||||
t.Fatalf("scope %q leaked: %+v %v", scope, got, err)
|
||||
}
|
||||
}
|
||||
valid := "scene @[tool:style:1:label:Palette]"
|
||||
expanded, err := svc.ResolveToolMentionTokens("alice", "image", valid)
|
||||
if err != nil || strings.Contains(expanded, "@[tool:") {
|
||||
t.Fatalf("expand: %s %v", expanded, err)
|
||||
}
|
||||
for _, token := range []string{"@[tool:style:0:x:X]", "@[tool:effect:1:x:X]", "@[tool:style:999999:x:X]", "@[tool:bogus:1:x:X]", "@[tool:style:1:broken]"} {
|
||||
if _, err := svc.ResolveToolMentionTokens("alice", "image", token); err == nil {
|
||||
t.Fatalf("accepted %s", token)
|
||||
}
|
||||
}
|
||||
if _, err := svc.ResolveToolMentionTokens("alice", "video", valid); err == nil {
|
||||
t.Fatal("wrong mode accepted")
|
||||
}
|
||||
if _, err := svc.ResolveToolMentionTokens("", "image", valid); err == nil {
|
||||
t.Fatal("anonymous accepted")
|
||||
}
|
||||
if err := db.Model(&model.Tool{}).Where("id = ?", 1).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ResolveToolMentionTokens("alice", "image", valid); err == nil {
|
||||
t.Fatal("disabled accepted")
|
||||
}
|
||||
if err := svc.Delete("alice", custom.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.ToolFavorite{}).Where("tool_id = ?", custom.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("orphan favorites")
|
||||
}
|
||||
if err := db.Model(&model.Tool{}).Where("id = ?", 1).Updates(map[string]any{"source": "user", "owner_id": "alice", "label": "keep"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tools.EnsureBuiltinTools(repo); err == nil {
|
||||
t.Fatal("seed collision accepted")
|
||||
}
|
||||
var preserved model.Tool
|
||||
db.First(&preserved, 1)
|
||||
if preserved.Label != "keep" {
|
||||
t.Fatal("user tool overwritten")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
//go:embed seed/tools.json
|
||||
var builtinToolsJSON []byte
|
||||
|
||||
type toolSeedItem struct {
|
||||
ID int64 `json:"id"`
|
||||
LabelEn string `json:"label_en"`
|
||||
Label string `json:"label"`
|
||||
Desc string `json:"desc"`
|
||||
Tag string `json:"tag"`
|
||||
Cover string `json:"cover"`
|
||||
ExtraInfo []string `json:"extra_info"`
|
||||
Prompt string `json:"prompt"`
|
||||
Ratio string `json:"ratio"`
|
||||
MediaURL string `json:"media_url"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Visibility string `json:"visibility"`
|
||||
CreateAt string `json:"create_at"`
|
||||
UpdateAt string `json:"update_at"`
|
||||
}
|
||||
|
||||
type toolSeedGroup struct {
|
||||
Title string `json:"title"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
List []toolSeedItem `json:"list"`
|
||||
}
|
||||
|
||||
type builtinToolsFile struct {
|
||||
Style toolSeedGroup `json:"style"`
|
||||
Motion toolSeedGroup `json:"motion"`
|
||||
NineGrid toolSeedGroup `json:"nine_grid"`
|
||||
}
|
||||
|
||||
const (
|
||||
ToolTypeStyle = "style"
|
||||
ToolTypeMotion = "motion"
|
||||
ToolTypeNineGrid = "nine_grid"
|
||||
ToolTypeEffect = "effect"
|
||||
)
|
||||
|
||||
const seedTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// EnsureBuiltinTools 将预设工具列表幂等写入数据库;重复启动只更新内容字段,不删除已有数据。
|
||||
// 初始化不下载第三方媒体,避免启动依赖外部 CDN 或产生无归属资源。
|
||||
func EnsureBuiltinTools(repo Repository) error {
|
||||
var file builtinToolsFile
|
||||
if err := json.Unmarshal(builtinToolsJSON, &file); err != nil {
|
||||
return fmt.Errorf("解析内置工具失败: %w", err)
|
||||
}
|
||||
|
||||
var tools []model.Tool
|
||||
sortWeight := 0
|
||||
|
||||
groups := []struct {
|
||||
typ string
|
||||
group toolSeedGroup
|
||||
}{
|
||||
{ToolTypeStyle, file.Style},
|
||||
{ToolTypeMotion, file.Motion},
|
||||
{ToolTypeNineGrid, file.NineGrid},
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{})
|
||||
for _, g := range groups {
|
||||
for _, item := range g.group.List {
|
||||
if item.ID <= 0 {
|
||||
return fmt.Errorf("内置工具 ID 无效: type=%s label_en=%q", g.typ, item.LabelEn)
|
||||
}
|
||||
if _, exists := seen[item.ID]; exists {
|
||||
return fmt.Errorf("内置工具 ID 重复: %d", item.ID)
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
uniqueKey := fmt.Sprintf("%s:%d", g.typ, item.ID)
|
||||
|
||||
labelEn := strings.TrimSpace(item.LabelEn)
|
||||
if labelEn == "" {
|
||||
return fmt.Errorf("内置工具英文标识为空: %s", uniqueKey)
|
||||
}
|
||||
label := strings.TrimSpace(item.Label)
|
||||
if label == "" {
|
||||
return fmt.Errorf("内置工具名称为空: %s", uniqueKey)
|
||||
}
|
||||
prompt := strings.TrimSpace(item.Prompt)
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("内置工具提示词为空: %s", uniqueKey)
|
||||
}
|
||||
visibility := strings.TrimSpace(item.Visibility)
|
||||
if visibility != "public" && visibility != "private" {
|
||||
return fmt.Errorf("内置工具可见性无效: %s visibility=%q", uniqueKey, item.Visibility)
|
||||
}
|
||||
|
||||
createdAt, err := parseSeedTime(item.CreateAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("内置工具创建时间无效: %s: %w", uniqueKey, err)
|
||||
}
|
||||
updatedAt, err := parseSeedTime(item.UpdateAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("内置工具更新时间无效: %s: %w", uniqueKey, err)
|
||||
}
|
||||
|
||||
extraInfoJSON := ""
|
||||
if len(item.ExtraInfo) > 0 {
|
||||
data, err := json.Marshal(item.ExtraInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化工具 %s 扩展信息失败: %w", uniqueKey, err)
|
||||
}
|
||||
extraInfoJSON = string(data)
|
||||
}
|
||||
|
||||
cover := strings.TrimSpace(item.Cover)
|
||||
mediaURL := strings.TrimSpace(item.MediaURL)
|
||||
sortWeight++
|
||||
tools = append(tools, model.Tool{
|
||||
ID: item.ID,
|
||||
Type: g.typ,
|
||||
LabelEn: labelEn,
|
||||
Label: label,
|
||||
Desc: strings.TrimSpace(item.Desc),
|
||||
Tag: strings.TrimSpace(item.Tag),
|
||||
Cover: cover,
|
||||
ExtraInfoJSON: extraInfoJSON,
|
||||
Prompt: prompt,
|
||||
Ratio: strings.TrimSpace(item.Ratio),
|
||||
MediaURL: mediaURL,
|
||||
OwnerID: item.OwnerID,
|
||||
Source: ToolSourceBuiltin,
|
||||
Enabled: item.Enabled,
|
||||
Visibility: visibility,
|
||||
SortWeight: sortWeight,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(tools) == 0 {
|
||||
return fmt.Errorf("内置工具列表为空")
|
||||
}
|
||||
if err := repo.UpsertBuiltinTools(tools); err != nil {
|
||||
return fmt.Errorf("同步内置工具失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSeedTime(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, fmt.Errorf("时间为空")
|
||||
}
|
||||
return time.ParseInLocation(seedTimeLayout, value, time.Local)
|
||||
}
|
||||
Reference in new issue
Block a user