feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填

选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。

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

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

Refs #547

Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
ddCatandbensharp committed 2026-09-20 16:40:08 +08:00
1 parent be5f2733b6
commit e93bf3ca39
66 files changed
+6127 -97

No files matched your search

+56
View File
@@ -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
+51
View File
@@ -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
}
+407
View File
@@ -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")
}
}
+163
View File
@@ -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)
}