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
@@ -218,6 +218,8 @@ func verifyMigrationCoverage(db *gorm.DB) error {
|
||||
|
||||
func migrations() []tableMigration {
|
||||
return []tableMigration{
|
||||
migrateTable[model.Tool]("tools"),
|
||||
migrateTable[model.ToolFavorite]("tool_favorites"),
|
||||
migrateTable[model.User]("users"),
|
||||
migrateTable[model.AuthSession]("auth_sessions"),
|
||||
migrateTable[model.UserIdentity]("user_identities"),
|
||||
|
||||
@@ -91,6 +91,9 @@ func run(ctx context.Context) error {
|
||||
if err := svc.EnsureSkillPackages(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.EnsureBuiltinTools(); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary, err := svc.MigrateLegacyStorage(); err != nil {
|
||||
log.Printf("storage migration skipped after error: %v", err)
|
||||
} else if summary.Backup != "" {
|
||||
|
||||
@@ -24,7 +24,7 @@ func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
&model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{},
|
||||
&model.Task{}, &model.TaskLog{}, &model.Result{},
|
||||
&model.CreationRun{}, &model.CreationSubmission{},
|
||||
&model.CloudAgentResourceLease{},
|
||||
&model.CloudAgentResourceLease{}, &model.Tool{},
|
||||
&model.Announcement{}, &model.AnnouncementImageDraft{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -347,6 +347,12 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
if strings.TrimSpace(input.Prompt) == "" {
|
||||
return nil, errors.New("prompt is required")
|
||||
}
|
||||
// 将 @[tool:type:ID:label:icon] 令牌替换为对应工具的提示词文本
|
||||
resolved, err := s.ResolveToolMentionTokens(userID, input.Mode, input.Prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input.Prompt = resolved
|
||||
config, err := s.resolveProviderConfig(input.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"infinite-canvas/backend/internal/prompts"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"infinite-canvas/backend/internal/skills"
|
||||
"infinite-canvas/backend/internal/tools"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -67,6 +68,7 @@ type Service struct {
|
||||
routeCatalogRetryAt time.Time
|
||||
routeCatalogRefreshError error
|
||||
skills *skills.Service
|
||||
tools *tools.Service
|
||||
prompts *prompts.Service
|
||||
auth *auth.Service
|
||||
canvas *canvas.Service
|
||||
@@ -116,6 +118,7 @@ func newService(repo *repository.Repository, dataDir string) *Service {
|
||||
service.taskWorkerCoordinator = newTaskWorkerCoordinator(service)
|
||||
service.taskLifecycleCoordinator = newTaskLifecycleCoordinator(service)
|
||||
service.skills = skills.New(service.repo, service.dataDir, service.runWorkerLoop)
|
||||
service.tools = tools.New(service.repo)
|
||||
service.prompts = prompts.New(service.repo, promptAdminGate{svc: service})
|
||||
service.auth = auth.New(service.repo, authHost{svc: service}, nil)
|
||||
service.canvas = canvas.New(service.repo, canvasHost{svc: service})
|
||||
|
||||
@@ -44,6 +44,24 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fail admission before queueing or charging; the worker validates again in
|
||||
// case a tool is deleted or its visibility changes while queued.
|
||||
toolMode, _ := normalizedInput["mode"].(string)
|
||||
if toolMode == "" {
|
||||
if strings.HasPrefix(taskType, "video_") || taskType == "canvas_video" {
|
||||
toolMode = "video"
|
||||
} else if taskType == "canvas_image" {
|
||||
toolMode = "image"
|
||||
}
|
||||
}
|
||||
if _, err := s.ResolveToolMentionTokens(userID, toolMode, prompt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inputPrompt, ok := normalizedInput["prompt"].(string); ok && inputPrompt != prompt {
|
||||
if _, err := s.ResolveToolMentionTokens(userID, toolMode, inputPrompt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var routed *RoutedModel
|
||||
logicalModelID := strings.TrimSpace(req.LogicalModelID)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/tools"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
ToolListRequest = tools.ToolListRequest
|
||||
ToolList = tools.ToolList
|
||||
ToolItem = tools.ToolItem
|
||||
ToolSummary = tools.ToolSummary
|
||||
ToolMutationRequest = tools.ToolMutationRequest
|
||||
)
|
||||
|
||||
func (s *Service) toolDomain() *tools.Service {
|
||||
if s == nil {
|
||||
return tools.New(nil)
|
||||
}
|
||||
if s.tools != nil {
|
||||
return s.tools
|
||||
}
|
||||
return tools.New(s.repo)
|
||||
}
|
||||
|
||||
func (s *Service) Tools(userID string, req ToolListRequest) (*ToolList, error) {
|
||||
return s.toolDomain().List(userID, req)
|
||||
}
|
||||
|
||||
func (s *Service) ToolDetail(userID string, toolID int64) (*ToolItem, error) {
|
||||
return s.toolDomain().Detail(userID, toolID)
|
||||
}
|
||||
|
||||
func (s *Service) SetToolFavorite(userID string, toolID int64, favorite bool) (*ToolItem, error) {
|
||||
return s.toolDomain().SetFavorite(userID, toolID, favorite)
|
||||
}
|
||||
|
||||
func (s *Service) CreateTool(userID string, req ToolMutationRequest) (*ToolItem, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return nil, Unauthorized("请先登录")
|
||||
}
|
||||
values := append([]string{req.Cover, req.MediaURL}, req.ExtraInfo...)
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
id := resourceIDFromFileURL(value)
|
||||
if id == "" {
|
||||
return nil, BadAuthRequest("工具预览仅支持本人上传的资源")
|
||||
}
|
||||
resource, err := s.repo.ResourceForUser(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
return nil, BadAuthRequest("预览资源尚未上传完成")
|
||||
}
|
||||
if req.Visibility == tools.ToolVisibilityPublic {
|
||||
return nil, BadAuthRequest("公开工具暂不支持私人预览资源,请移除预览或改为私有")
|
||||
}
|
||||
}
|
||||
return s.toolDomain().Create(userID, req)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteTool(userID string, toolID int64) error {
|
||||
return s.toolDomain().Delete(userID, toolID)
|
||||
}
|
||||
|
||||
// ResolveToolMentionTokens 将 prompt 中的 @[tool:type:ID:label:icon] 令牌替换为对应工具的提示词文本。
|
||||
func (s *Service) ResolveToolMentionTokens(userID, mode, prompt string) (string, error) {
|
||||
return s.toolDomain().ResolveToolMentionTokens(userID, mode, prompt)
|
||||
}
|
||||
|
||||
func (s *Service) EnsureBuiltinTools() error {
|
||||
return tools.EnsureBuiltinTools(s.repo)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolPreviewOwnershipAndResourceDeletionGuard(t *testing.T) {
|
||||
svc, db, _ := newResourceDeletionTestService(t)
|
||||
resource := model.Resource{ID: "tool-preview", UserID: "alice", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "preview.png"}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := ToolMutationRequest{Type: "style", Label: "style", Prompt: "film", Cover: "/api/resources/tool-preview/file", Visibility: "private"}
|
||||
if _, err := svc.CreateTool("bob", req); err == nil {
|
||||
t.Fatal("foreign resource accepted")
|
||||
}
|
||||
req.Visibility = "public"
|
||||
if _, err := svc.CreateTool("alice", req); err == nil {
|
||||
t.Fatal("private resource exposed publicly")
|
||||
}
|
||||
req.Visibility = "private"
|
||||
tool, err := svc.CreateTool("alice", req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := svc.repo.ResourceReferenceSnapshot("alice", "", []string{resource.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, doc := range snapshot.Documents {
|
||||
if doc.Kind == "工具" && documentReferencesResources(doc.PrimaryJSON, map[string]struct{}{resource.ID: {}}) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("tool preview not protected from resource deletion")
|
||||
}
|
||||
if err := svc.DeleteTool("alice", tool.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatal("deleting tool deleted preview resource")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolAdmissionRejectsBeforeQueueing(t *testing.T) {
|
||||
svc, db, _ := newResourceDeletionTestService(t)
|
||||
_, err := svc.CreateTask("alice", CreateTaskRequest{Type: "canvas_image", Prompt: "@[tool:style:999:x:X]", Input: map[string]any{"mode": "image", "prompt": "@[tool:style:999:x:X]"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "工具") {
|
||||
t.Fatalf("unexpected admission result: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Task{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("invalid tool was queued")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 29
|
||||
const CurrentSchemaVersion int64 = 31
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
@@ -99,6 +99,12 @@ var schemaMigrations = []migration{
|
||||
{version: 29, name: "agent_resource_leases", checksum: "sha256:agent-resource-leases-v29-20260919", apply: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.CloudAgentResourceLease{})
|
||||
}},
|
||||
{version: 30, name: "builtin_tools", checksum: "sha256:builtin-tools-v30", apply: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.Tool{})
|
||||
}},
|
||||
{version: 31, name: "tool_favorites", checksum: "sha256:tool-favorites-v31", apply: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.ToolFavorite{})
|
||||
}},
|
||||
}
|
||||
|
||||
func migrateChannelCreditCost(tx *gorm.DB) error {
|
||||
|
||||
@@ -599,3 +599,76 @@ func TestMigrateSchemaV13AddsCloudAgentCanvasMutation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaV30AddsBuiltinTools(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-builtin-tools-v30?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.Tool{}) {
|
||||
t.Fatal("migration v30 did not create tools table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaV31AddsToolUserActions(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-tool-user-actions-v31?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.ToolFavorite{}) {
|
||||
t.Fatal("migration v31 did not create tool_favorites table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsUpgradeFromMain29PreservesMigrationChecksums(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:tools-main29-upgrade?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range schemaMigrations {
|
||||
if item.version > 29 {
|
||||
break
|
||||
}
|
||||
if err := item.apply(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The initial migration uses today's model registry; restore the actual v29
|
||||
// boundary so this test proves that v30/v31 create the new tables.
|
||||
if err := db.Migrator().DropTable(&model.ToolFavorite{}, &model.Tool{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.Migrator().HasTable(&model.Tool{}) || db.Migrator().HasTable(&model.ToolFavorite{}) {
|
||||
t.Fatal("tool tables must not exist before upgrading v29")
|
||||
}
|
||||
for range 2 {
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, version := range []int64{28, 29} {
|
||||
var record schemaMigration
|
||||
if err := db.First(&record, version).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := map[int64]string{28: "sha256:agent-execution-journal-v28", 29: "sha256:agent-resource-leases-v29-20260919"}
|
||||
if record.Checksum != expected[version] {
|
||||
t.Fatal("main checksum changed")
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.Tool{}) || !db.Migrator().HasTable(&model.ToolFavorite{}) {
|
||||
t.Fatal("tools tables missing")
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ func Models() []any {
|
||||
&model.SkillVersion{},
|
||||
&model.SkillFile{},
|
||||
&model.UserSkillState{},
|
||||
&model.Tool{},
|
||||
&model.ToolFavorite{},
|
||||
&model.Resource{},
|
||||
&model.ResourceDeletionJob{},
|
||||
&model.AnnouncementImageDraft{},
|
||||
|
||||
@@ -37,6 +37,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
|
||||
RegisterTaskRoutes(api, svc)
|
||||
RegisterRunningHubRoutes(api, svc)
|
||||
RegisterSkillRoutes(api, svc)
|
||||
RegisterToolRoutes(api, svc)
|
||||
RegisterUserDataRoutes(api, svc)
|
||||
RegisterChunkedUploadRoutes(api, svc)
|
||||
RegisterDiagnosticsRoutes(api, svc)
|
||||
|
||||
@@ -26,6 +26,62 @@ tags:
|
||||
- name: agent
|
||||
- name: admin
|
||||
paths:
|
||||
/tools:
|
||||
get:
|
||||
tags: [canvas]
|
||||
summary: 列出可见且启用的生成工具
|
||||
parameters:
|
||||
- {in: query, name: scope, schema: {type: string, enum: [public, favorites, custom], default: public}}
|
||||
- {in: query, name: type, schema: {type: string, enum: [style, motion, nine_grid, effect]}}
|
||||
- {in: query, name: tag, schema: {type: string}}
|
||||
- {in: query, name: search, schema: {type: string}}
|
||||
- {in: query, name: page, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {in: query, name: pageSize, schema: {type: integer, minimum: 1, maximum: 80, default: 20}}
|
||||
responses:
|
||||
"200": {description: "标准信封 data 为 tools(摘要不含 prompt/extraInfo)、totalCount、page、pageSize、hasMore"}
|
||||
"401": {description: 未登录}
|
||||
post:
|
||||
tags: [canvas]
|
||||
summary: 创建当前用户的工具
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ToolMutation'}
|
||||
responses:
|
||||
"200": {description: 标准信封 data 为完整工具}
|
||||
"400": {description: 参数无效或预览资源不符合要求}
|
||||
"401": {description: 未登录}
|
||||
/tools/{id}:
|
||||
parameters:
|
||||
- {in: path, name: id, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
get:
|
||||
tags: [canvas]
|
||||
summary: 查询可见且启用工具的完整详情
|
||||
responses:
|
||||
"200": {description: 标准信封 data 为完整工具,包含 prompt 和 extraInfo}
|
||||
"404": {description: 工具不存在或不可见}
|
||||
delete:
|
||||
tags: [canvas]
|
||||
summary: 删除自己的用户工具及关联收藏,不删除预览文件
|
||||
responses:
|
||||
"200": {description: "标准信封 data 为 {deleted: true}"}
|
||||
"404": {description: 工具不存在或不能删除}
|
||||
/tools/{id}/favorite:
|
||||
parameters:
|
||||
- {in: path, name: id, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
post:
|
||||
tags: [canvas]
|
||||
summary: 幂等收藏可见工具
|
||||
responses:
|
||||
"200": {description: 标准信封 data 为完整工具和收藏状态}
|
||||
"404": {description: 工具不存在或不可见}
|
||||
delete:
|
||||
tags: [canvas]
|
||||
summary: 幂等取消收藏可见工具
|
||||
responses:
|
||||
"200": {description: 标准信封 data 为完整工具和收藏状态}
|
||||
"404": {description: 工具不存在或不可见}
|
||||
/model-catalog:
|
||||
get:
|
||||
tags: [models]
|
||||
@@ -971,6 +1027,20 @@ components:
|
||||
in: cookie
|
||||
name: open_ai_canvas_session
|
||||
schemas:
|
||||
ToolMutation:
|
||||
type: object
|
||||
required: [type, label, prompt]
|
||||
properties:
|
||||
type: {type: string, enum: [style, motion, nine_grid, effect]}
|
||||
label: {type: string, description: 名称最多 120 字节}
|
||||
prompt: {type: string, description: 最多 8000 字节,不允许嵌套工具标签}
|
||||
visibility: {type: string, enum: [private, public], default: private}
|
||||
desc: {type: string}
|
||||
tag: {type: string}
|
||||
cover: {type: string, description: 本人已上传完成的资源 URL,仅限私有工具}
|
||||
mediaUrl: {type: string, description: 本人已上传完成的资源 URL,仅限私有工具}
|
||||
extraInfo: {type: array, items: {type: string}}
|
||||
ratio: {type: string}
|
||||
ModelRequestIntent:
|
||||
type: object
|
||||
required: [capability]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterToolRoutes 注册画布工具 API:查询、收藏、新增、删除。
|
||||
func RegisterToolRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/tools", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, pageSize, err := parsePaginationQuery(c, 20)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.Tools(user.ID, service.ToolListRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Scope: c.DefaultQuery("scope", "public"),
|
||||
Type: c.Query("type"),
|
||||
Tag: c.Query("tag"),
|
||||
Search: c.Query("search"),
|
||||
})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
|
||||
r.GET("/tools/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
toolID, err := parseToolID(c)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := svc.ToolDetail(user.ID, toolID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, item)
|
||||
})
|
||||
|
||||
r.POST("/tools", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req service.ToolMutationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
failService(c, service.BadAuthRequest("请求体格式无效"))
|
||||
return
|
||||
}
|
||||
item, err := svc.CreateTool(user.ID, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, item)
|
||||
})
|
||||
|
||||
r.POST("/tools/:id/favorite", func(c *gin.Context) {
|
||||
setToolFavorite(c, svc, true)
|
||||
})
|
||||
|
||||
r.DELETE("/tools/:id/favorite", func(c *gin.Context) {
|
||||
setToolFavorite(c, svc, false)
|
||||
})
|
||||
|
||||
r.DELETE("/tools/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
toolID, err := parseToolID(c)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := svc.DeleteTool(user.ID, toolID); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"deleted": true})
|
||||
})
|
||||
}
|
||||
|
||||
func setToolFavorite(c *gin.Context, svc *service.Service, favorite bool) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
toolID, err := parseToolID(c)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := svc.SetToolFavorite(user.ID, toolID, favorite)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, item)
|
||||
}
|
||||
|
||||
func parseToolID(c *gin.Context) (int64, error) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, errInvalidToolID
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
type toolIDError string
|
||||
|
||||
func (e toolIDError) Error() string { return string(e) }
|
||||
|
||||
const errInvalidToolID = toolIDError("工具 ID 无效")
|
||||
@@ -147,3 +147,32 @@ type UserSkillState struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Type string `json:"type" gorm:"size:24;index"`
|
||||
LabelEn string `json:"labelEn" gorm:"size:120;index"`
|
||||
Label string `json:"label" gorm:"size:120"`
|
||||
Desc string `json:"desc" gorm:"size:500"`
|
||||
Tag string `json:"tag" gorm:"size:64;index"`
|
||||
Cover string `json:"cover" gorm:"size:500"`
|
||||
ExtraInfoJSON string `json:"-" gorm:"type:text"`
|
||||
Prompt string `json:"prompt" gorm:"type:text"`
|
||||
Ratio string `json:"ratio" gorm:"size:32"`
|
||||
MediaURL string `json:"mediaUrl" gorm:"size:500"`
|
||||
OwnerID string `json:"ownerId" gorm:"size:36;index"`
|
||||
Source string `json:"source" gorm:"size:16;index"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
Visibility string `json:"visibility" gorm:"size:16;index"`
|
||||
SortWeight int `json:"sortWeight" gorm:"index"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"index"`
|
||||
}
|
||||
|
||||
// ToolFavorite 保存用户对工具的收藏关系;一个用户对同一工具只能收藏一次。
|
||||
type ToolFavorite struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_tool_favorite_user_tool,priority:1"`
|
||||
ToolID int64 `json:"toolId" gorm:"index;uniqueIndex:idx_tool_favorite_user_tool,priority:2"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -100,6 +102,23 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
|
||||
snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "Agent 待执行引用", ID: lease.OwnerID, Title: "已准备的生成输入", ResourceID: lease.ResourceID})
|
||||
}
|
||||
|
||||
var toolRecords []model.Tool
|
||||
if err := r.db.Where("owner_id = ?", userID).Find(&toolRecords).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
for _, tool := range toolRecords {
|
||||
var extra []string
|
||||
if tool.ExtraInfoJSON != "" {
|
||||
if err := json.Unmarshal([]byte(tool.ExtraInfoJSON), &extra); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{"coverUrl": tool.Cover, "url": tool.MediaURL, "referenceUrls": extra})
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工具", ID: strconv.FormatInt(tool.ID, 10), Title: tool.Label, PrimaryJSON: string(payload)})
|
||||
}
|
||||
var assets []model.Asset
|
||||
assetQuery := r.db.Where("user_id = ? AND id <> ?", userID, excludingAssetID)
|
||||
if err := assetQuery.Find(&assets).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/tools"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// UpsertBuiltinTools 按 id 幂等更新内置工具;created_at 保留首次写入值。
|
||||
func (r *Repository) UpsertBuiltinTools(tools []model.Tool) error {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
ids := make([]int64, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
ids = append(ids, tool.ID)
|
||||
}
|
||||
var collisions int64
|
||||
if err := tx.Model(&model.Tool{}).Where("id IN ? AND source <> ?", ids, "builtin").Count(&collisions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if collisions > 0 {
|
||||
return errors.New("内置工具 ID 与用户工具冲突,拒绝覆盖")
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: clause.Column{Table: "tools", Name: "source"}, Value: "builtin"}}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"type", "label_en", "label", "desc", "tag", "cover", "extra_info_json", "prompt", "ratio",
|
||||
"media_url", "owner_id", "source", "enabled", "visibility", "sort_weight", "updated_at",
|
||||
}),
|
||||
}).Create(&tools)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != int64(len(tools)) {
|
||||
return errors.New("内置工具写入遇到 ID 冲突,已回滚")
|
||||
}
|
||||
// 内置工具显式写入自增主键,PostgreSQL 的序列不会自动前移;这里同步到当前最大值,
|
||||
// 避免用户自建工具从 1 重新分配而与内置工具主键冲突。
|
||||
if r.Dialect() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
return tx.Exec("SELECT setval(pg_get_serial_sequence('tools', 'id'), (SELECT COALESCE(MAX(id), 1) FROM tools), true)").Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListTools 按范围查询工具列表并携带当前用户收藏状态。
|
||||
func (r *Repository) ListTools(userID string, req tools.ToolListRequest) ([]tools.ToolWithFavorite, int64, error) {
|
||||
base := r.db.Table("tools").Where("enabled = ? AND (tools.visibility = ? OR tools.owner_id = ?)", true, tools.ToolVisibilityPublic, userID)
|
||||
switch req.Scope {
|
||||
case tools.ToolScopeFavorites:
|
||||
base = base.Joins("JOIN tool_favorites tf ON tf.tool_id = tools.id AND tf.user_id = ?", userID)
|
||||
default: // public / custom
|
||||
base = base.Joins("LEFT JOIN tool_favorites tf ON tf.tool_id = tools.id AND tf.user_id = ?", userID)
|
||||
}
|
||||
|
||||
// 可见性:公共 scope 只看 public(含他人公开),其余 scope 只看自己可见的
|
||||
switch req.Scope {
|
||||
case tools.ToolScopePublic:
|
||||
base = base.Where("tools.visibility = ?", tools.ToolVisibilityPublic)
|
||||
case tools.ToolScopeFavorites:
|
||||
base = base.Where("tf.user_id = ?", userID)
|
||||
case tools.ToolScopeCustom:
|
||||
base = base.Where("tools.owner_id = ? AND tools.source = ?", userID, tools.ToolSourceUser)
|
||||
}
|
||||
|
||||
if req.Type != "" {
|
||||
base = base.Where("tools.type = ?", req.Type)
|
||||
}
|
||||
if req.Tag != "" {
|
||||
base = base.Where("tools.tag = ?", req.Tag)
|
||||
}
|
||||
if req.Search != "" {
|
||||
keyword := "%" + strings.ToLower(req.Search) + "%"
|
||||
base = base.Where("(LOWER(tools.label) LIKE ? OR LOWER(tools.label_en) LIKE ? OR LOWER(tools.desc) LIKE ?)", keyword, keyword, keyword)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
orderBy := "tools.sort_weight ASC, tools.id ASC"
|
||||
switch req.Scope {
|
||||
case tools.ToolScopeFavorites:
|
||||
orderBy = "tf.created_at DESC, tools.id DESC"
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
var rows []struct {
|
||||
model.Tool
|
||||
TFID *int64 `gorm:"column:tf_id"`
|
||||
TFCreatedAt *time.Time `gorm:"column:tf_created_at"`
|
||||
}
|
||||
// 列表只取摘要字段,不取 extra_info_json、prompt 两个大字段;列与 tools.ToolSummary 对齐。
|
||||
selectColumns := "tools.id, tools.type, tools.label_en, tools.label, tools.desc, tools.tag, tools.cover, " +
|
||||
"tools.ratio, tools.media_url, tools.owner_id, tools.source, tools.enabled, tools.visibility, " +
|
||||
"tools.sort_weight, tools.created_at, tools.updated_at, tf.id AS tf_id, tf.created_at AS tf_created_at"
|
||||
err := base.Session(&gorm.Session{}).
|
||||
Select(selectColumns).
|
||||
Order(orderBy).
|
||||
Offset(offset).
|
||||
Limit(req.PageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
items := make([]tools.ToolWithFavorite, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, tools.ToolWithFavorite{
|
||||
Tool: row.Tool,
|
||||
Favorited: row.TFID != nil,
|
||||
FavoritedAt: row.TFCreatedAt,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// ToolForUser 校验可见性后返回工具。
|
||||
func (r *Repository) ToolForUser(userID string, toolID int64) (model.Tool, error) {
|
||||
var tool model.Tool
|
||||
err := r.db.Where("id = ? AND enabled = ? AND (visibility = ? OR owner_id = ?)", toolID, true, tools.ToolVisibilityPublic, userID).
|
||||
First(&tool).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return tool, kernel.NotFound("工具不存在或不可见")
|
||||
}
|
||||
if err != nil {
|
||||
return tool, err
|
||||
}
|
||||
return tool, nil
|
||||
}
|
||||
|
||||
// ToolFavorited 返回工具及收藏时间(未收藏时为 nil)。
|
||||
func (r *Repository) ToolFavorited(userID string, toolID int64) (model.Tool, *time.Time, error) {
|
||||
tool, err := r.ToolForUser(userID, toolID)
|
||||
if err != nil {
|
||||
return tool, nil, err
|
||||
}
|
||||
var favorite model.ToolFavorite
|
||||
err = r.db.Where("user_id = ? AND tool_id = ?", userID, toolID).First(&favorite).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return tool, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return tool, nil, err
|
||||
}
|
||||
return tool, &favorite.CreatedAt, nil
|
||||
}
|
||||
|
||||
// AddToolFavorite 幂等添加收藏;已收藏时直接成功。
|
||||
func (r *Repository) AddToolFavorite(userID string, toolID int64) error {
|
||||
favorite := model.ToolFavorite{UserID: userID, ToolID: toolID, CreatedAt: time.Now()}
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "tool_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(&favorite).Error
|
||||
}
|
||||
|
||||
// RemoveToolFavorite 取消收藏;未收藏时也无错误。
|
||||
func (r *Repository) RemoveToolFavorite(userID string, toolID int64) error {
|
||||
return r.db.Where("user_id = ? AND tool_id = ?", userID, toolID).
|
||||
Delete(&model.ToolFavorite{}).Error
|
||||
}
|
||||
|
||||
// CreateTool 创建用户自定义工具。
|
||||
func (r *Repository) CreateTool(tool *model.Tool) (*model.Tool, error) {
|
||||
if err := r.db.Create(tool).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tool, nil
|
||||
}
|
||||
|
||||
// DeleteUserTool 仅允许删除自己的自定义工具;事务内同步清理收藏记录。
|
||||
func (r *Repository) DeleteUserTool(userID string, toolID int64) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var tool model.Tool
|
||||
err := tx.Where("id = ? AND owner_id = ? AND source = ?", toolID, userID, tools.ToolSourceUser).
|
||||
First(&tool).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return kernel.NotFound("工具不存在或不支持删除")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("tool_id = ?", toolID).Delete(&model.ToolFavorite{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.Tool{}, toolID).Error
|
||||
})
|
||||
}
|
||||
@@ -285,6 +285,11 @@ type (
|
||||
StorageMigrationSummary = app.StorageMigrationSummary
|
||||
StyleProfileFavoriteRequest = app.StyleProfileFavoriteRequest
|
||||
StyleProfileRequest = app.StyleProfileRequest
|
||||
ToolItem = app.ToolItem
|
||||
ToolList = app.ToolList
|
||||
ToolListRequest = app.ToolListRequest
|
||||
ToolMutationRequest = app.ToolMutationRequest
|
||||
ToolSummary = app.ToolSummary
|
||||
SystemPerformanceCacheGroup = app.SystemPerformanceCacheGroup
|
||||
SystemPerformanceDisk = app.SystemPerformanceDisk
|
||||
SystemPerformanceHost = app.SystemPerformanceHost
|
||||
|
||||
@@ -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