Files
ttstd-infinite-canvas/backend/internal/tools/tools.go
ddCat e93bf3ca39 feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填
选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。

补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。

验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。

Refs #547

Co-authored-by: bensharp <bensharp@qq.com>
2026-09-20 16:40:08 +08:00

408 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}