diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 42239010f..99ead9a4a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -92,6 +92,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) diff --git a/backend/internal/handler/plugin.go b/backend/internal/handler/plugin.go new file mode 100644 index 000000000..6846e0a1d --- /dev/null +++ b/backend/internal/handler/plugin.go @@ -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}) + }) +} diff --git a/backend/internal/service/eagle.go b/backend/internal/service/eagle.go new file mode 100644 index 000000000..921d2eb75 --- /dev/null +++ b/backend/internal/service/eagle.go @@ -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 +} diff --git a/backend/internal/service/eagle_thumbnail.go b/backend/internal/service/eagle_thumbnail.go new file mode 100644 index 000000000..818aeedd3 --- /dev/null +++ b/backend/internal/service/eagle_thumbnail.go @@ -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 +} diff --git a/docs/content/docs/plugins/plugin-system.mdx b/docs/content/docs/plugins/plugin-system.mdx index c975b8f0b..a4147f446 100644 --- a/docs/content/docs/plugins/plugin-system.mdx +++ b/docs/content/docs/plugins/plugin-system.mdx @@ -1,13 +1,13 @@ --- title: 插件系统设计 -description: 影策网页插件系统的目标、扩展边界、插件合同与 Eagle 素材连接器方案 +description: 影策网页插件系统的目标、扩展边界、插件合同与 Eagle 外部素材来源方案 --- # 插件系统设计 -> 状态:设计草案 +> 状态:已实现首版(Eagle 外部素材来源) > -> 本文定义后续网页插件系统的目标和接口边界,不代表当前版本已经实现全部能力。 +> 当前版本已经包含 Eagle 专属素材管理页面、后端本地桥接、原始文件树浏览、文件读取和写入;第三方远程插件安装与完整 SDK 仍按后续阶段建设。 ## 1. 背景与范围 @@ -171,20 +171,18 @@ export interface PluginSetupApi { 5. 卸载阶段清理插件存储、事件订阅、注册命令和可选的插件节点数据。 6. 更新失败时保留旧版本,不能留下半启用状态。 -## 8. Eagle 素材连接器方案 +## 8. Eagle 外部素材来源方案 Eagle 插件的主分类是“素材与数据连接器”,可以附带一个 `EagleAsset` 画布节点。 ### 8.1 连接器职责 -- 配置 Eagle 本地地址、端口和访问 Token。 -- 检查 Eagle 服务是否可用。 -- 浏览库、文件夹和标签。 -- 按关键词、标签和文件类型搜索素材。 -- 将素材以 `resource:` 或宿主资源合同导入画布。 -- 将画布中的生成图片、视频或音频上传回 Eagle。 -- 保存 Eagle 资产 ID、库 ID、文件夹 ID 和来源信息。 -- 提供“在 Eagle 中打开”和“重新同步”动作。 +- 使用 Eagle 本地 API(默认 `http://127.0.0.1:41595`)检查服务并读取当前素材库。 +- 在影策素材页进入 Eagle 专属素材管理页面,按 Eagle 的父子文件树浏览和搜索文件。 +- Eagle 文件仍由 Eagle 管理,影策只通过外部素材来源读取、下载和使用,不要求逐个选择或复制成本地素材。 +- 在当前 Eagle 文件夹中新建文件夹,并把本地图片、视频和音频直接写入 Eagle。 +- 返回 Eagle 素材 ID、文件夹 ID、完整文件夹路径和来源信息,供影策项目保存外部引用。 +- 通过影策后端本地桥接读取 Eagle 文件,避免浏览器直接打开 Eagle 的本机文件路径。 ### 8.2 画布节点 @@ -218,27 +216,30 @@ type EagleAssetMetadata = { ```text asset.read asset.search -asset.import asset.upload -canvas.read -canvas.write +external.open ``` 删除 Eagle 资源、批量覆盖和修改原始文件属于高风险权限,应单独申请并逐次确认,不能包含在普通 `asset.write` 中。 ### 8.4 推荐交互 -第一阶段只做单向流程: +首版提供外部素材库浏览和双向写入: ```text -Eagle 搜索 → 选择素材 → 导入画布 → 参与创作 +Eagle 文件树 / 搜索 → 打开素材 → 在影策中使用外部引用 +影策上传图片、视频或音频 → 选择 Eagle 目标文件夹 → 写入 Eagle +影策生成图片、视频或音频 → 插件自动归档到 Eagle 目标文件夹 ``` -第二阶段再做回写: +### 8.5 当前实现边界 -```text -画布生成结果 → 用户确认 → 上传 Eagle → 自动归档和打标签 -``` +- 素材页的“Eagle 素材库”进入插件专属页面,不使用抽屉,也不要求选择素材。 +- 左侧文件树直接继承 Eagle 的父子层级;影策保存外部引用时保留 Eagle 素材 ID、文件夹 ID 和路径信息。 +- 图片、视频、音频和模型可以从 Eagle 直接浏览、搜索和读取;当前不把文本类内容转换成影策文本素材。 +- 当前支持将本地图片、视频和音频写入 Eagle 当前文件夹;生成结果可以在插件设置中开启自动归档并指定 Eagle 文件夹。影策仍保留本地素材记录,外部写入成功或失败会记录到素材 metadata。实际可写入格式由本机 Eagle 版本/API 决定;如果 Eagle 拒绝某种媒体,影策保留本地结果并记录失败状态。删除、批量覆盖和冲突解决仍需后续权限设计。 +- Eagle 地址只允许本机默认端口,后端会校验本机地址和文件路径,避免把连接器变成任意内网访问代理。 +- Eagle 文件夹不会复制成影策项目中的独立资产文件夹;页面以 Eagle 原始文件树作为外部来源视图。自动归档默认使用 Eagle 根目录,用户可以在插件设置中关闭或改用具体文件夹。 ## 9. 安全边界 @@ -289,7 +290,7 @@ plugins/ - 本地插件发现。 - 安装、启用、停用、更新和卸载。 - 节点注册、工具栏命令、事件和插件存储。 -- Eagle 只读搜索和素材导入。 +- Eagle 外部素材来源页面、媒体写入和生成结果自动归档。 ### Phase 2:连接器和工作流 @@ -314,7 +315,7 @@ plugins/ - 用户能看到插件名称、版本、来源和权限。 - 插件停用后不再响应事件,也不残留工具栏命令。 - 插件更新失败时旧版本仍可用。 -- Eagle 插件可以搜索素材并把选中素材导入画布。 +- Eagle 插件可以浏览原始文件树、搜索和读取素材,并将图片、视频和音频写入 Eagle;用户可以配置生成结果是否自动归档及目标文件夹。 - Eagle Token 不进入画布节点、任务正文、日志或 URL。 - 插件错误和权限拒绝能够显示给用户。 diff --git a/docs/design/canvas-node-visual-contrast.mdx b/docs/design/canvas-node-visual-contrast.mdx new file mode 100644 index 000000000..7ae4261a5 --- /dev/null +++ b/docs/design/canvas-node-visual-contrast.mdx @@ -0,0 +1,76 @@ +# 画布节点可读性设计沉淀:节点与图片创作面板 + +> 范围:无限画布中的节点外壳、节点内容空态、节点标题和图片创作面板。 +> 目标:节点在浅色、深色,以及点阵/线稿/空白画布背景上都能被快速识别和操作。 +> 状态:已实现首版,浏览器回归待确认。 + +## 1. 问题定义 + +画布节点可能直接落在白色或黑色背景上。只依赖全局卡片表面会造成两个问题: + +- 节点边界消失,空态内容看起来像画布的一部分。 +- 图片创作面板的“将使用默认模型与参数”提示、输入区和操作控件缺少稳定的表面层级。 + +这不是生成逻辑或参考图尺寸逻辑的问题,因此本次只调整视觉契约,不改变节点数据、生成参数、定位和交互流程。 + +## 2. 设计决策 + +### 2.1 节点使用画布主题 token + +`canvasThemes`(画布主题 token)中的 `node` 语义负责节点自身的可读性: + +- `fill`:节点主体表面。 +- `panel`:节点内部面板和空态子表面。 +- `edge`:节点与画布之间的低对比边界。 +- `shadow` / `hoverShadow`:静止与悬停时的空间层级。 +- `text` / `muted` / `faint`:正文、辅助说明和占位内容。 + +浅色和深色主题必须成对提供这些 token。节点不能回退到全局 `--card-surface`,因为它不知道当前画布背景和节点层级。 + +### 2.2 节点外壳与空态 + +`CanvasNode`(画布节点外壳)使用 `node.fill`、`node.edge` 和主题阴影。选中状态继续使用现有 active stroke,边界只用于提供识别度,不作为装饰性竖线或强调色。 + +`CanvasNodeContent`(节点内容渲染)的绘图空态在节点内部增加一个轻量子表面和图标边界,使“打开绘图”在白色和黑色背景下都能被识别;真实媒体内容不增加额外遮罩,不改变原始比例。 + +### 2.3 图片创作面板同步适配 + +`CanvasNodePromptPanel`(图片/视频/文本/音频创作面板)把面板变量绑定到同一组画布主题: + +- 面板表面使用 `node.panel`,边界使用 `node.edge`,阴影使用 `node.shadow`。 +- 模型选择器、参考素材、放大编辑和参数折叠按钮使用 `toolbar.itemHover` 与 `toolbar.activeBg`,不再依赖只适合深色背景的白色 hover。 +- 展开编辑 Modal 使用相同的表面、边界和阴影,避免节点内面板与放大面板产生两套视觉语言。 +- “将使用默认模型与参数”仍是状态说明,不改变模型解析或生成默认值。 + +## 3. 状态与边界 + +| 状态 | 处理 | 目的 | +| --- | --- | --- | +| 默认 | `node.panel` + `node.edge` + `node.shadow` | 与点阵、线稿或空白背景分离 | +| 悬停 | `hoverShadow`,控件使用 `activeBg` | 提示可操作性,不改变布局 | +| 选中 | 沿用 active stroke | 与节点选中态保持一致 | +| 禁用/无内容 | 沿用 `faint` 和既有空态文案 | 不伪造生成成功或配置状态 | +| 展开编辑 | Modal 复用同一套主题 token | 保持放大编辑与节点内编辑一致 | + +本次不调整画布背景色、节点尺寸、面板定位、参考图尺寸选项、生成参数和资源同步逻辑。 + +## 4. 实现地图 + +- `web/src/lib/canvas-theme.ts`:维护浅色/深色节点边界和阴影 token。 +- `web/src/components/canvas/canvas-node.tsx`:应用节点外壳和节点标题的主题表面。 +- `web/src/components/canvas/canvas-node-content.tsx`:应用绘图空态子表面。 +- `web/src/components/canvas/canvas-node-prompt-panel.tsx`:注入图片创作面板和放大编辑面板的主题变量。 +- `web/src/styles/globals.css`:维护面板控件 hover 与尺寸调整手柄的唯一规则。 + +## 5. 验证与待回归 + +已完成: + +- `git diff --check` 通过。 +- Impeccable 受限扫描未发现本次新增的反模式;扫描到的渐变文字、宽高过渡和既有布局规则位于其他历史区域。 + +待确认: + +- 浏览器中分别切换浅色/深色主题,检查图片创作面板默认、悬停、选中和展开编辑状态。 +- 在点阵、线稿和空白背景下检查节点边缘、空绘图态和“将使用默认模型与参数”的对比度。 +- 本项目按规则未自动运行 TypeScript、构建或测试;提交前只做了静态 diff 检查。 \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 6ad8644d4..cabfad530 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,8 @@ - [工作区外壳设计沉淀](design/workspace-shell-design.mdx):侧栏(260px 可折叠导航 + 分组折叠)、主区卡片、顶部栏(账户/公告/主题)的设计决策与样式约束。 +- [画布节点可读性设计沉淀](design/canvas-node-visual-contrast.mdx):节点外壳、空态和图片创作面板在浅色/深色画布上的表面、边界、阴影与控件状态约束。 + ## 本地协作文档(不随仓库分发) - [beautifului 创作设计](beautifului-creation-design.md):本地设计参考,未纳入版本控制。 diff --git a/web/package.json b/web/package.json index 5afb8831a..638ece336 100644 --- a/web/package.json +++ b/web/package.json @@ -74,6 +74,7 @@ "@types/node": "^20.19.43", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "@types/three": "0.185.4", "@vitejs/plugin-react": "^6.0.3", "prettier": "^3.9.5", "typescript": "^7.0.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cc78fc1e1..d88d63e7a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.17) + '@types/three': + specifier: 0.185.4 + version: 0.185.4 '@vitejs/plugin-react': specifier: ^6.0.3 version: 6.0.5(vite@8.2.1(@types/node@20.19.43)(jiti@2.7.0)) diff --git a/web/src/application.tsx b/web/src/application.tsx index 62d2c9c8e..d193b8f52 100644 --- a/web/src/application.tsx +++ b/web/src/application.tsx @@ -4,6 +4,8 @@ import "antd/dist/reset.css"; import "./styles/globals.css"; import { RouterProvider } from "react-router"; +import "@/lib/plugins/builtin"; + import { AppProviders } from "@/components/layout/app-providers"; import { router } from "@/router"; diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 4c0fa2aa3..98be1fa1a 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -1,10 +1,13 @@ -import { Button, Modal } from "antd"; -import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react"; +import { Button, Dropdown, Modal } from "antd"; +import type { MenuProps } from "antd"; +import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, Search, Upload, UserRound, Video } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AssetMediaPreview } from "@/components/asset-media-preview"; +import { AssetLibraryCard } from "@/components/assets/asset-library-card"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { cn } from "@/lib/utils"; +import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types"; import type { Asset } from "@/stores/use-asset-store"; export type AssetLibraryPickerItem = { @@ -20,6 +23,7 @@ export type AssetLibraryPickerItem = { searchText?: string; disabledReason?: string; folderId?: string; + external?: ExternalAssetPickerReference; }; export type AssetLibraryPickerFolder = { @@ -44,10 +48,16 @@ type Props = { emptyDescription?: string; footerNote?: string; folderActionLabel?: string; + folderActionSource?: "local" | "all"; upload?: { accept: string; description: string; onUpload: (files: FileList) => Promise; + external?: { + accept: string; + description: string; + onUpload: (files: FileList, folderId?: string) => Promise; + }; }; onClose: () => void; onConfirm: (ids: string[]) => Promise | void; @@ -70,6 +80,7 @@ export function AssetLibraryPickerModal({ emptyDescription = "换个分类后再试。", footerNote, folderActionLabel = "将文件夹放到画布", + folderActionSource = "all", upload, onClose, onConfirm, @@ -77,8 +88,11 @@ export function AssetLibraryPickerModal({ }: Props) { const [category, setCategory] = useState(initialCategory); const [folderId, setFolderId] = useState(initialFolderId); + const [source, setSource] = useState<"local" | "plugin">("local"); + const [sourceMenuOpen, setSourceMenuOpen] = useState(false); const [keyword, setKeyword] = useState(""); const [selected, setSelected] = useState>(new Set()); + const [uploadedItems, setUploadedItems] = useState([]); const [working, setWorking] = useState(false); const [uploadingCount, setUploadingCount] = useState(0); const [error, setError] = useState(""); @@ -86,26 +100,42 @@ export function AssetLibraryPickerModal({ const initialSelectedIdsRef = useRef(initialSelectedIds); const itemsRef = useRef(items); initialSelectedIdsRef.current = initialSelectedIds; - itemsRef.current = items; - const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category || "other")))], [items]); + const allItems = useMemo(() => { + const known = new Set(items.map((item) => item.id)); + return [...items, ...uploadedItems.filter((item) => !known.has(item.id))]; + }, [items, uploadedItems]); + itemsRef.current = allItems; + const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]); + const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]); + const hasPluginSource = useMemo( + () => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), + [categoryLabels, pluginItems], + ); + const sourceItems = source === "plugin" ? pluginItems : localItems; + const sourceFolders = source === "plugin" ? folders : []; + const showCategories = source === "local" || !sourceFolders.length; + const categories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other")))], [sourceItems]); const visibleItems = useMemo(() => { const query = keyword.trim().toLowerCase(); - return items.filter((item) => { + return sourceItems.filter((item) => { if (category !== "all" && item.category !== category) return false; if (folderId !== "all" && (item.folderId || "") !== folderId) return false; return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); }); - }, [category, folderId, items, keyword]); + }, [category, folderId, keyword, sourceItems]); const selectedIds = useMemo( - () => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id), - [items, selected], + () => allItems.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id), + [allItems, selected], ); useEffect(() => { if (!open) return; - setCategory(initialCategory); + setFolderId(initialFolderId); + setCategory(initialCategory); + setSource("local"); setKeyword(""); + setUploadedItems([]); const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id)); setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id)))); setWorking(false); @@ -118,6 +148,19 @@ export function AssetLibraryPickerModal({ setCategory("all"); }, [categories, category]); + useEffect(() => { + if (hasPluginSource || source === "local") return; + setSource("local"); + }, [hasPluginSource, source]); + + const selectSource = (nextSource: "local" | "plugin") => { + if (nextSource === "plugin" && !hasPluginSource) return; + setSource(nextSource); + setCategory("all"); + setFolderId("all"); + setError(""); + }; + const toggle = (item: AssetLibraryPickerItem) => { if (item.disabledReason || working) return; setError(""); @@ -144,13 +187,20 @@ export function AssetLibraryPickerModal({ }; const handleUpload = async (files: FileList | null) => { - if (!upload || !files?.length || working) return; + if (!files?.length || working || (source === "local" && !upload) || (source === "plugin" && !upload?.external)) return; setWorking(true); setError(""); setUploadingCount(files.length); try { - const ids = await upload.onUpload(files); - if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1))); + if (source === "plugin") { + const uploaded = await upload!.external!.onUpload(files, folderId === "all" ? undefined : folderId); + setUploadedItems((current) => [...current, ...uploaded]); + const ids = uploaded.map((item) => item.id); + if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1))); + } else { + const ids = await upload!.onUpload(files); + if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1))); + } } catch (reason) { setError(reason instanceof Error ? reason.message : "素材上传失败,请重试"); } finally { @@ -173,7 +223,13 @@ export function AssetLibraryPickerModal({ } }; - const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length; + const countFor = (value: string) => value === "all" ? sourceItems.length : sourceItems.filter((item) => item.category === value).length; + const sourceLabel = source === "plugin" ? "插件来源" : "本地素材"; + const sourceMenuItems: MenuProps["items"] = [ + { key: "local", icon: