feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界

This commit is contained in:
ddCat
2026-08-22 10:01:41 +08:00
45 changed files with 5649 additions and 240 deletions

View File

@@ -96,6 +96,7 @@ func main() {
handler.RegisterSessionRoutes(api, svc)
handler.RegisterSkillRoutes(api, svc)
handler.RegisterUserDataRoutes(api, svc)
handler.RegisterPluginRoutes(api, svc)
projectAPI := api.Group("")
projectAPI.Use(handler.RequireFeature(svc, service.FeatureShortDrama))
handler.RegisterProjectRoutes(projectAPI, svc)

View File

@@ -0,0 +1,109 @@
package handler
import (
"net/http"
"strconv"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) {
r.GET("/plugins/eagle/library", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
library, err := svc.EagleLibrary(c.Query("baseUrl"))
if err != nil {
failService(c, err)
return
}
library.LibraryPath = ""
ok(c, gin.H{"library": library})
})
r.GET("/plugins/eagle/items", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "60"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, err := svc.EagleItems(c.Query("baseUrl"), service.EagleItemQuery{FolderID: c.Query("folderId"), Keyword: c.Query("keyword"), Limit: limit, Offset: offset})
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"items": items})
})
r.GET("/plugins/eagle/items/:itemId/file", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
file, err := svc.OpenEagleItemFile(c.Query("baseUrl"), c.Param("itemId"))
if err != nil {
failService(c, err)
return
}
defer file.Body.Close()
c.Header("Cache-Control", "private, no-store")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", "attachment; filename=\""+file.Name+"\"")
c.DataFromReader(http.StatusOK, file.Size, file.MimeType, file.Body, nil)
})
r.GET("/plugins/eagle/items/:itemId/thumbnail", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
file, err := svc.OpenEagleItemThumbnail(c.Query("baseUrl"), c.Param("itemId"))
if err != nil {
failService(c, err)
return
}
defer file.Body.Close()
c.Header("Cache-Control", "private, max-age=60")
c.Header("X-Content-Type-Options", "nosniff")
c.DataFromReader(http.StatusOK, file.Size, file.MimeType, file.Body, nil)
})
r.POST("/plugins/eagle/items", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 160<<20)
var request service.EagleAddItemRequest
if err := c.ShouldBindJSON(&request); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
item, err := svc.AddEagleItem(c.Query("baseUrl"), request)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"item": item})
})
r.POST("/plugins/eagle/folders", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
var request struct {
Name string `json:"name"`
ParentID string `json:"parentId"`
}
if err := c.ShouldBindJSON(&request); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
if err := svc.CreateEagleFolder(c.Query("baseUrl"), request.Name, request.ParentID); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"created": true})
})
}

View File

@@ -0,0 +1,400 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const eagleDefaultBaseURL = "http://127.0.0.1:41595"
type EagleFolder struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parentId,omitempty"`
Children []EagleFolder `json:"children,omitempty"`
}
type EagleLibrary struct {
ApplicationVersion string `json:"applicationVersion"`
LibraryName string `json:"libraryName"`
LibraryPath string `json:"-"`
Folders []EagleFolder `json:"folders"`
}
type EagleItem struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"extension"`
Tags []string `json:"tags"`
FolderIDs []string `json:"folderIds"`
URL string `json:"url"`
Annotation string `json:"annotation"`
ModificationTime int64 `json:"modificationTime"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Deleted bool `json:"deleted"`
}
func (item *EagleItem) UnmarshalJSON(data []byte) error {
var raw struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"ext"`
Tags []string `json:"tags"`
FolderIDs []string `json:"folders"`
URL string `json:"url"`
Annotation string `json:"annotation"`
ModificationTime int64 `json:"modificationTime"`
Width int `json:"width"`
Height int `json:"height"`
Deleted bool `json:"isDeleted"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*item = EagleItem{
ID: raw.ID, Name: raw.Name, Size: raw.Size, Extension: raw.Extension,
Tags: raw.Tags, FolderIDs: raw.FolderIDs, URL: raw.URL, Annotation: raw.Annotation,
ModificationTime: raw.ModificationTime, Width: raw.Width, Height: raw.Height, Deleted: raw.Deleted,
}
return nil
}
type EagleItemQuery struct {
FolderID string
Keyword string
Limit int
Offset int
}
type EagleAddItemRequest struct {
URL string `json:"url"`
Name string `json:"name"`
FolderID string `json:"folderId,omitempty"`
Tags []string `json:"tags,omitempty"`
Annotation string `json:"annotation,omitempty"`
Website string `json:"website,omitempty"`
ModificationTime int64 `json:"modificationTime,omitempty"`
}
type EagleCreatedItem struct {
ID string `json:"id,omitempty"`
}
type EagleFile struct {
Path string
Name string
Size int64
MimeType string
Body io.ReadCloser
}
func (s *Service) EagleLibrary(rawBaseURL string) (*EagleLibrary, error) {
baseURL, err := validateEagleBaseURL(rawBaseURL)
if err != nil {
return nil, err
}
var response struct {
Status string `json:"status"`
Data struct {
Folders []EagleFolder `json:"folders"`
ApplicationVersion string `json:"applicationVersion"`
Library struct {
Path string `json:"path"`
Name string `json:"name"`
} `json:"library"`
} `json:"data"`
}
if err := eagleJSONRequest(http.MethodGet, baseURL, "/api/library/info", nil, &response); err != nil {
return nil, err
}
if response.Status != "success" {
return nil, errors.New("Eagle 未返回成功状态,请确认 Eagle 已启动并打开素材库")
}
folders := flattenEagleFolders(response.Data.Folders, "")
return &EagleLibrary{
ApplicationVersion: response.Data.ApplicationVersion,
LibraryName: response.Data.Library.Name,
LibraryPath: response.Data.Library.Path,
Folders: folders,
}, nil
}
func (s *Service) EagleItems(rawBaseURL string, query EagleItemQuery) ([]EagleItem, error) {
baseURL, err := validateEagleBaseURL(rawBaseURL)
if err != nil {
return nil, err
}
limit := query.Limit
if limit <= 0 || limit > 200 {
limit = 60
}
offset := query.Offset
if offset < 0 {
offset = 0
}
params := url.Values{}
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("offset", fmt.Sprintf("%d", offset))
if query.FolderID != "" {
params.Set("folders", query.FolderID)
}
if strings.TrimSpace(query.Keyword) != "" {
params.Set("keyword", strings.TrimSpace(query.Keyword))
}
var response struct {
Status string `json:"status"`
Data []EagleItem `json:"data"`
}
if err := eagleJSONRequest(http.MethodGet, baseURL, "/api/item/list?"+params.Encode(), nil, &response); err != nil {
return nil, err
}
if response.Status != "success" {
return nil, errors.New("Eagle 未返回素材列表")
}
for index := range response.Data {
item := &response.Data[index]
item.Extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item.Extension)), ".")
item.FolderIDs = append([]string(nil), item.FolderIDs...)
item.Tags = append([]string(nil), item.Tags...)
}
return response.Data, nil
}
func (s *Service) OpenEagleItemFile(rawBaseURL string, itemID string) (*EagleFile, error) {
if strings.TrimSpace(itemID) == "" || strings.ContainsAny(itemID, "/\\?&") {
return nil, BadAuthRequest("Eagle 素材 ID 无效")
}
baseURL, err := validateEagleBaseURL(rawBaseURL)
if err != nil {
return nil, err
}
library, err := s.EagleLibrary(rawBaseURL)
if err != nil {
return nil, err
}
var response struct {
Status string `json:"status"`
Data string `json:"data"`
}
pathQuery := "/api/item/thumbnail?id=" + url.QueryEscape(itemID)
if err := eagleJSONRequest(http.MethodGet, baseURL, pathQuery, nil, &response); err != nil {
return nil, err
}
if response.Status != "success" || strings.TrimSpace(response.Data) == "" {
return nil, errors.New("Eagle 未返回素材路径")
}
thumbnailPath, err := url.PathUnescape(response.Data)
if err != nil {
return nil, errors.New("Eagle 素材路径编码无效")
}
originalPath, err := eagleOriginalPath(thumbnailPath, itemID, library.LibraryPath)
if err != nil {
return nil, err
}
file, err := os.Open(originalPath)
if err != nil {
return nil, errors.New("无法读取 Eagle 原始文件,请确认素材库仍处于可用状态")
}
stat, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
mimeType := mime.TypeByExtension(filepath.Ext(originalPath))
if mimeType == "" {
mimeType = "application/octet-stream"
}
return &EagleFile{Path: originalPath, Name: filepath.Base(originalPath), Size: stat.Size(), MimeType: mimeType, Body: file}, nil
}
func (s *Service) AddEagleItem(rawBaseURL string, request EagleAddItemRequest) (*EagleCreatedItem, error) {
if !isEagleMediaDataURL(request.URL) {
return nil, BadAuthRequest("写入 Eagle 只接受图片、视频或音频数据")
}
if strings.TrimSpace(request.Name) == "" {
return nil, BadAuthRequest("写入 Eagle 时必须提供素材名称")
}
baseURL, err := validateEagleBaseURL(rawBaseURL)
if err != nil {
return nil, err
}
var response struct {
Status string `json:"status"`
Data EagleCreatedItem `json:"data"`
}
if err := eagleJSONRequest(http.MethodPost, baseURL, "/api/item/addFromURL", request, &response); err != nil {
return nil, err
}
if response.Status != "success" {
return nil, errors.New("Eagle 拒绝写入素材")
}
return &response.Data, nil
}
func isEagleMediaDataURL(value string) bool {
trimmed := strings.TrimSpace(value)
if len(trimmed) < len("data:,") || !strings.EqualFold(trimmed[:5], "data:") {
return false
}
comma := strings.IndexByte(trimmed, ',')
if comma <= len("data:") {
return false
}
mediaType := strings.TrimSpace(trimmed[len("data:"):comma])
if semicolon := strings.IndexByte(mediaType, ';'); semicolon >= 0 {
mediaType = mediaType[:semicolon]
}
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
return strings.HasPrefix(mediaType, "image/") || strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/")
}
func (s *Service) CreateEagleFolder(rawBaseURL string, name string, parentID string) error {
if strings.TrimSpace(name) == "" {
return BadAuthRequest("Eagle 文件夹名称不能为空")
}
baseURL, err := validateEagleBaseURL(rawBaseURL)
if err != nil {
return err
}
payload := struct {
FolderName string `json:"folderName"`
Parent string `json:"parent,omitempty"`
}{FolderName: strings.TrimSpace(name), Parent: strings.TrimSpace(parentID)}
var response struct {
Status string `json:"status"`
}
if err := eagleJSONRequest(http.MethodPost, baseURL, "/api/folder/create", payload, &response); err != nil {
return err
}
if response.Status != "success" {
return errors.New("Eagle 文件夹创建失败")
}
return nil
}
func validateEagleBaseURL(raw string) (*url.URL, error) {
value := strings.TrimSpace(raw)
if value == "" {
value = eagleDefaultBaseURL
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "http" || parsed.User != nil || parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, BadAuthRequest("Eagle 地址必须是 http://127.0.0.1:41595")
}
host := strings.ToLower(parsed.Hostname())
if host != "127.0.0.1" && host != "localhost" && host != "::1" {
return nil, BadAuthRequest("为避免内网代理风险,Eagle 插件只允许连接本机地址")
}
if parsed.Port() != "41595" {
return nil, BadAuthRequest("当前 Eagle 插件只支持默认端口 41595")
}
parsed.Path = ""
return parsed, nil
}
func eagleJSONRequest(method string, baseURL *url.URL, endpoint string, payload any, target any) error {
requestURL := *baseURL
endpointURL, err := url.Parse(endpoint)
if err != nil || endpointURL.IsAbs() || endpointURL.Host != "" || endpointURL.Path == "" {
return errors.New("Eagle API 路径无效")
}
requestURL.Path = strings.TrimRight(requestURL.Path, "/") + endpointURL.Path
requestURL.RawQuery = endpointURL.RawQuery
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
body = strings.NewReader(string(encoded))
}
request, err := http.NewRequestWithContext(context.Background(), method, requestURL.String(), body)
if err != nil {
return err
}
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
// Eagle 只允许连接回环地址;本机 API 必须绕过 Clash,否则代理可能把本机请求改写成 404。
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
client := &http.Client{Timeout: 2 * time.Minute, Transport: transport}
response, err := client.Do(request)
if err != nil {
return errors.New("无法连接 Eagle,请确认 Eagle 已启动并打开素材库")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
if response.StatusCode == http.StatusNotFound {
return errors.New("Eagle 未找到当前接口或素材,请确认 Eagle 已打开素材库并使用默认 API 地址")
}
return fmt.Errorf("Eagle API 返回 HTTP %d", response.StatusCode)
}
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
return errors.New("Eagle API 返回了无法解析的数据")
}
return nil
}
func eagleOriginalPath(thumbnailPath string, itemID string, libraryPath string) (string, error) {
thumbnailPath = filepath.Clean(filepath.FromSlash(thumbnailPath))
libraryPath = filepath.Clean(filepath.FromSlash(libraryPath))
if libraryPath == "." || !filepath.IsAbs(libraryPath) {
return "", errors.New("Eagle 素材库路径无效,无法安全读取原文件")
}
itemDir := filepath.Join(libraryPath, "images", itemID+".info")
rel, err := filepath.Rel(itemDir, thumbnailPath)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return "", errors.New("Eagle 素材路径不在当前素材库内")
}
base := filepath.Base(thumbnailPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
stem = strings.TrimSuffix(stem, "_thumbnail")
if ext := filepath.Ext(base); ext != "" {
candidate := filepath.Join(itemDir, stem+ext)
if stat, statErr := os.Stat(candidate); statErr == nil && !stat.IsDir() {
return candidate, nil
}
}
entries, err := os.ReadDir(itemDir)
if err != nil {
return "", errors.New("Eagle 原文件目录不存在")
}
candidates := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || strings.EqualFold(entry.Name(), "metadata.json") || strings.Contains(strings.ToLower(entry.Name()), "_thumbnail") {
continue
}
candidates = append(candidates, filepath.Join(itemDir, entry.Name()))
}
if len(candidates) == 0 {
return "", errors.New("Eagle 原始文件不存在")
}
sort.Strings(candidates)
return candidates[0], nil
}
func flattenEagleFolders(folders []EagleFolder, parentID string) []EagleFolder {
result := make([]EagleFolder, 0, len(folders))
for _, folder := range folders {
folder.ParentID = parentID
children := flattenEagleFolders(folder.Children, folder.ID)
folder.Children = nil
result = append(result, folder)
result = append(result, children...)
}
return result
}

View File

@@ -0,0 +1,59 @@
package service
import (
"errors"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
func (s *Service) OpenEagleItemThumbnail(rawBaseURL string, itemID string) (*EagleFile, error) {
if strings.TrimSpace(itemID) == "" || strings.ContainsAny(itemID, "/\\?&") {
return nil, BadAuthRequest("Eagle 素材 ID 无效")
}
baseURL, err := validateEagleBaseURL(rawBaseURL)
if err != nil {
return nil, err
}
library, err := s.EagleLibrary(rawBaseURL)
if err != nil {
return nil, err
}
var response struct {
Status string `json:"status"`
Data string `json:"data"`
}
if err := eagleJSONRequest(http.MethodGet, baseURL, "/api/item/thumbnail?id="+url.QueryEscape(itemID), nil, &response); err != nil {
return nil, err
}
if response.Status != "success" || strings.TrimSpace(response.Data) == "" {
return nil, errors.New("Eagle 未返回缩略图路径")
}
thumbnailPath, err := url.PathUnescape(response.Data)
if err != nil {
return nil, errors.New("Eagle 缩略图路径编码无效")
}
thumbnailPath = filepath.Clean(filepath.FromSlash(thumbnailPath))
itemDir := filepath.Join(filepath.Clean(filepath.FromSlash(library.LibraryPath)), "images", itemID+".info")
rel, err := filepath.Rel(itemDir, thumbnailPath)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return nil, errors.New("Eagle 缩略图路径不在当前素材库内")
}
file, err := os.Open(thumbnailPath)
if err != nil {
return nil, errors.New("无法读取 Eagle 缩略图")
}
stat, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
mimeType := mime.TypeByExtension(filepath.Ext(thumbnailPath))
if mimeType == "" {
mimeType = "application/octet-stream"
}
return &EagleFile{Path: thumbnailPath, Name: filepath.Base(thumbnailPath), Size: stat.Size(), MimeType: mimeType, Body: file}, nil
}