From 2f3a813ccb49719226674b9e28c940e2e9fbfefb Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Wed, 26 Aug 2026 13:11:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E6=8F=92=E4=BB=B6=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E7=B3=BB=E7=BB=9F=20-=20=E6=94=AF=E6=8C=81=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E5=BC=8F=E6=B8=A0=E9=81=93=E3=80=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E4=B8=8E=E5=8F=82=E8=80=83=E5=AA=92=E4=BD=93=E6=98=A0?= =?UTF-8?q?=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 1 - backend/internal/handler/auth.go | 2 +- backend/internal/handler/plugin.go | 39 +- backend/internal/handler/protocols.go | 35 -- .../protocol/autodl_plugin_artifact_test.go | 76 ++++ backend/internal/protocol/builtin.go | 15 + backend/internal/protocol/builtin_test.go | 38 +- .../internal/protocol/docs/autodl-comfyui.md | 28 ++ backend/internal/protocol/manifest.go | 429 ++++++++++++++---- .../internal/protocol/manifest_agent_test.go | 32 +- backend/internal/protocol/package.go | 136 ++++++ backend/internal/protocol/package_test.go | 62 +++ backend/internal/protocol/types.go | 212 ++++++++- backend/internal/service/protocol_plugins.go | 398 ++++++++++------ backend/internal/service/protocol_registry.go | 102 ++++- .../internal/service/protocol_runtime_test.go | 152 ++++++- backend/internal/service/provider.go | 5 +- backend/internal/service/provider_test.go | 1 + docs/content/docs/plugins/plugin-system.mdx | 58 +-- plugin-packages/autodl-comfyui.yingce-plugin | Bin 0 -> 2076 bytes plugin-packages/autodl-comfyui/manifest.json | 150 ++++++ .../components/canvas/canvas-context-menu.tsx | 6 +- .../components/canvas/canvas-node-content.tsx | 28 +- web/src/components/canvas/canvas-node.tsx | 4 +- web/src/components/canvas/canvas-toolbar.tsx | 4 +- web/src/lib/canvas/canvas-node-asset.ts | 4 +- web/src/lib/canvas/canvas-node-size.ts | 5 +- web/src/lib/canvas/canvas-project-domain.ts | 10 +- .../lib/canvas/canvas-resource-references.ts | 4 +- .../definitions/builtin-nodes.tsx | 5 +- web/src/lib/canvas/node-registry/index.ts | 2 +- .../canvas/node-registry/node-definition.ts | 24 +- .../lib/canvas/node-registry/node-registry.ts | 28 +- .../definitions/add-node-menu-tools.tsx | 4 +- .../canvas/tool-registry/tool-definition.ts | 6 +- web/src/lib/model-protocols.ts | 3 +- web/src/lib/model-selection.ts | 6 +- web/src/lib/plugins/builtin/eagle.ts | 14 +- .../lib/plugins/builtin/prompt-optimizer.ts | 6 +- web/src/lib/plugins/plugin-registry.ts | 8 +- web/src/lib/plugins/plugin-types.ts | 113 ++++- .../components/channel-model-manager.tsx | 4 +- .../canvas/canvas-project-context-menu.tsx | 4 +- web/src/pages/canvas/project.tsx | 1 + .../canvas/use-canvas-generation-retry.ts | 4 +- .../canvas/use-canvas-node-operations.ts | 4 +- web/src/pages/canvas/use-canvas-upload.ts | 2 +- web/src/pages/plugins/index.tsx | 100 ++-- .../pages/plugins/plugin-development-guide.md | 198 +++----- .../plugins/plugin-documentation-modals.tsx | 8 +- web/src/pages/plugins/plugin-documentation.ts | 6 +- .../pages/settings/channel-settings-pane.tsx | 42 +- .../pages/settings/channel-video-pricing.tsx | 4 +- web/src/services/api/plugin-catalog.ts | 46 ++ web/src/services/api/plugins.ts | 27 +- web/src/services/api/protocols.ts | 35 -- web/src/types/canvas.ts | 18 +- web/test/canvas-model-policy.test.ts | 22 + 58 files changed, 2074 insertions(+), 706 deletions(-) delete mode 100644 backend/internal/handler/protocols.go create mode 100644 backend/internal/protocol/autodl_plugin_artifact_test.go create mode 100644 backend/internal/protocol/docs/autodl-comfyui.md create mode 100644 backend/internal/protocol/package.go create mode 100644 backend/internal/protocol/package_test.go create mode 100644 plugin-packages/autodl-comfyui.yingce-plugin create mode 100644 plugin-packages/autodl-comfyui/manifest.json create mode 100644 web/src/services/api/plugin-catalog.ts delete mode 100644 web/src/services/api/protocols.ts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 14f4b2009..a6b18bc15 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -100,7 +100,6 @@ func main() { handler.RegisterUserDataRoutes(api, svc) handler.RegisterDiagnosticsRoutes(api, svc) handler.RegisterPluginRoutes(api, svc) - handler.RegisterProtocolRoutes(api, svc) projectAPI := api.Group("") projectAPI.Use(handler.RequireFeature(svc, service.FeatureShortDrama)) handler.RegisterProjectRoutes(projectAPI, svc) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index b7b6520a1..333a97d09 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -783,7 +783,7 @@ func shortSystemProxyPath(rawPath string) (string, string, bool) { // as a channel request when a business route returns 404. func isReservedAPIPathPrefix(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { - case "admin", "ai", "announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "protocols", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "user-data", "voice-profiles", "wallet": + case "admin", "ai", "announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "user-data", "voice-profiles", "wallet": return true default: return false diff --git a/backend/internal/handler/plugin.go b/backend/internal/handler/plugin.go index 457635d9a..67be8a58c 100644 --- a/backend/internal/handler/plugin.go +++ b/backend/internal/handler/plugin.go @@ -7,6 +7,7 @@ import ( "strings" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/protocol" "infinite-canvas/backend/internal/service" "github.com/gin-gonic/gin" @@ -30,6 +31,15 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"plugins": plugins}) }) + pluginRoutes.GET("/catalog", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + scope := strings.TrimSpace(c.DefaultQuery("scope", "user.custom-channel")) + capability := strings.TrimSpace(c.Query("capability")) + ok(c, gin.H{"providers": svc.PluginProviderCatalog(scope, capability, false)}) + }) pluginRoutes.POST("", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -40,30 +50,49 @@ func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 512<<10) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, protocol.PluginPackageMaxBytes) var data []byte + fileName := "" if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/form-data") { - file, _, fileErr := c.Request.FormFile("file") + file, header, fileErr := c.Request.FormFile("file") if fileErr != nil { fail(c, http.StatusBadRequest, fileErr) return } defer file.Close() - data, err = io.ReadAll(io.LimitReader(file, 512<<10+1)) + fileName = header.Filename + data, err = io.ReadAll(io.LimitReader(file, protocol.PluginPackageMaxBytes+1)) } else { - data, err = io.ReadAll(io.LimitReader(c.Request.Body, 512<<10+1)) + data, err = io.ReadAll(io.LimitReader(c.Request.Body, protocol.PluginPackageMaxBytes+1)) } if err != nil { fail(c, http.StatusBadRequest, err) return } - plugin, err := svc.InstallPlugin(data) + plugin, err := svc.InstallPlugin(data, fileName) if err != nil { failService(c, err) return } ok(c, gin.H{"plugin": plugin}) }) + pluginRoutes.GET("/:id/package", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + data, fileName, err := svc.PluginPackage(c.Param("id")) + if err != nil { + failService(c, err) + return + } + if fileName == "" { + fileName = c.Param("id") + ".yingce-plugin" + } + c.Header("Cache-Control", "private, no-store") + c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(fileName, "\"", "")+"\"") + c.Data(http.StatusOK, "application/zip", data) + }) pluginRoutes.POST("/:id/enable", pluginToggle(svc, true)) pluginRoutes.POST("/:id/disable", pluginToggle(svc, false)) pluginRoutes.DELETE("/:id", func(c *gin.Context) { diff --git a/backend/internal/handler/protocols.go b/backend/internal/handler/protocols.go deleted file mode 100644 index f1e2381b0..000000000 --- a/backend/internal/handler/protocols.go +++ /dev/null @@ -1,35 +0,0 @@ -package handler - -import ( - "strings" - - "infinite-canvas/backend/internal/service" - - "github.com/gin-gonic/gin" -) - -func RegisterProtocolRoutes(r *gin.RouterGroup, svc *service.Service) { - r.GET("/protocols", func(c *gin.Context) { - if _, err := currentUser(c, svc); err != nil { - failService(c, err) - return - } - scope := strings.TrimSpace(c.DefaultQuery("scope", "user.custom-channel")) - capability := strings.TrimSpace(c.Query("capability")) - ok(c, gin.H{"protocols": svc.ProtocolCatalog(scope, capability, false)}) - }) - r.GET("/admin/protocols", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.RequireAdmin(user); err != nil { - failService(c, err) - return - } - includeUnavailable := c.Query("includeUnavailable") == "1" - capability := strings.TrimSpace(c.Query("capability")) - ok(c, gin.H{"protocols": svc.ProtocolCatalog("admin.system-channel", capability, includeUnavailable)}) - }) -} diff --git a/backend/internal/protocol/autodl_plugin_artifact_test.go b/backend/internal/protocol/autodl_plugin_artifact_test.go new file mode 100644 index 000000000..12c057991 --- /dev/null +++ b/backend/internal/protocol/autodl_plugin_artifact_test.go @@ -0,0 +1,76 @@ +package protocol + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAutoDLPluginArtifact(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", "autodl-comfyui.yingce-plugin")) + if err != nil { + t.Fatal(err) + } + pkg, err := ParsePluginPackage(data) + if err != nil { + t.Fatal(err) + } + adapters, err := LoadInstalledProviders(pkg.ManifestRaw, nil) + if err != nil { + t.Fatal(err) + } + if len(adapters) != 1 { + t.Fatalf("loaded adapters = %d", len(adapters)) + } + adapter := adapters[0] + if adapter.Metadata().ID != "autodl-comfyui" || adapter.Metadata().Execution != "declarative" { + t.Fatalf("metadata = %#v", adapter.Metadata()) + } + if !adapter.Metadata().RequiresPublicMediaURLs { + t.Fatal("AutoDL provider must request public reference media URLs") + } + + create, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{ + Model: "minimax_h3_lightx2v_no_pic", Prompt: "a cinematic test", Duration: 3, Resolution: "768P", Images: []MediaReference{{URL: "https://cdn.example/reference.png"}}, + }}) + if err != nil { + t.Fatal(err) + } + if create.Method != "POST" || create.Path != "/api/v1/comfyui/comfyui_workflow/minimax_h3_lightx2v_no_pic" || create.ContentType != "application/json" { + t.Fatalf("create spec = %#v", create) + } + body, ok := create.Body.(map[string]any) + if !ok || body["prompt"] != "a cinematic test" || body["duration"] != 3 || body["resolution"] != "768p" || body["ref_image_0"] != "https://cdn.example/reference.png" { + t.Fatalf("create body = %#v", create.Body) + } + if _, exists := body["ref_image_1"]; exists { + t.Fatalf("missing reference image was not omitted: %#v", body) + } + + failed, err := adapter.ParseCreate(context.Background(), []byte(`{"code":"RequestParameterIsWrong","data":null,"msg":"参数: resolution 的值: 768P 不在 options 列表中"}`)) + if err != nil || failed.Status != StatusFailed || failed.Message == "" { + t.Fatalf("failed = %#v, err = %v", failed, err) + } + + created, err := adapter.ParseCreate(context.Background(), []byte(`{"code":0,"data":{"task_id":"task-auto-1","status":"QUEUED"}}`)) + if err != nil || created.TaskID != "task-auto-1" || created.Status != StatusPending { + t.Fatalf("created = %#v, err = %v", created, err) + } + poll, err := adapter.BuildPoll(context.Background(), PollContext{BaseURL: "https://autodl.art", Model: "minimax_h3_lightx2v_no_pic", TaskID: created.TaskID}) + if err != nil || poll.Method != "GET" || poll.Path != "/api/v1/comfyui/comfyui_workflow/result/task-auto-1" { + t.Fatalf("poll spec = %#v, err = %v", poll, err) + } + completed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: created.TaskID}, []byte(`{"code":"Success","data":{"task_id":"task-auto-1","status":"completed","results":[{"url":"https://cdn.example/video.mp4","type":"video","file_type":"mp4"}]}}`)) + if err != nil || completed.Status != StatusSucceeded || completed.Result == nil || len(completed.Result.Videos) != 1 { + t.Fatalf("completed = %#v, err = %v", completed, err) + } + video := completed.Result.Videos[0] + if video.URL != "https://cdn.example/video.mp4" || video.Kind != "video" || !video.Ephemeral { + t.Fatalf("video = %#v", video) + } + if _, err := json.Marshal(completed); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/protocol/builtin.go b/backend/internal/protocol/builtin.go index 12359b584..367f54a45 100644 --- a/backend/internal/protocol/builtin.go +++ b/backend/internal/protocol/builtin.go @@ -84,6 +84,21 @@ func Builtins() *Registry { return registry } +// BundledManifest returns the canonical unified manifest for a declarative +// bundled contribution. Host-backed builtins are generated by the installer +// because their execution engine lives in the application binary. +func BundledManifest(id string) (Manifest, bool) { + adapter, ok := Builtins().Get(id) + if !ok { + return Manifest{}, false + } + manifest, ok := adapter.(manifestAdapter) + if !ok { + return Manifest{}, false + } + return manifest.manifest, true +} + func allSurfaces() []Surface { return []Surface{SurfaceAdminSystemChannel, SurfaceUserCustomChannel, SurfaceCanvas, SurfaceCreation, SurfaceAgent} } diff --git a/backend/internal/protocol/builtin_test.go b/backend/internal/protocol/builtin_test.go index 7a1663424..436425dc8 100644 --- a/backend/internal/protocol/builtin_test.go +++ b/backend/internal/protocol/builtin_test.go @@ -42,6 +42,9 @@ func TestBuiltinCatalogContainsRequestedProtocols(t *testing.T) { if !found { t.Fatal("Agnes metadata was omitted from the administrator catalog") } + if _, ok := registry.Get("autodl-comfyui"); ok { + t.Fatal("AutoDL must be provided by the uploaded plugin package, not the host registry") + } } func TestTextAdaptersMapRequestsAndResponses(t *testing.T) { @@ -216,11 +219,9 @@ func TestAgnesIsExplicitlyUnavailable(t *testing.T) { func TestDeclarativeManifestMapsFieldsAndResponses(t *testing.T) { manifest := []byte(`{ - "apiVersion":"v1", - "metadata":{"id":"example-video","version":"1.0.0","name":"Example Video","vendor":"Example","categories":["video"],"scopes":["admin.system-channel","user.custom-channel"],"documentation":"# Example Video"}, - "create":{"method":"POST","path":"/v1/tasks","fields":{"model":"request.model","input.prompt":"request.prompt","input.seconds":"request.duration"}}, - "poll":{"method":"GET","path":"/v1/tasks/{{taskId}}"}, - "response":{"taskIdPaths":["id","data.id"],"statusPaths":["status","data.status"],"resultUrlPaths":["result.video_url","data.result.video_url"],"resultKind":"video"} + "apiVersion":"yingce.plugin/v1", + "id":"example-video","version":"1.0.0","name":"Example Video","author":"Example","documentation":"# Example Video", + "contributes":{"providers":[{"id":"example-video","label":"Example Video","capabilities":["video"],"scopes":["admin.system-channel","user.custom-channel"],"create":{"method":"POST","path":"/v1/tasks","fields":{"model":"request.model","input.prompt":"request.prompt","input.seconds":"request.duration"}},"poll":{"method":"GET","path":"/v1/tasks/{{taskId}}"},"response":{"taskIdPaths":["id","data.id"],"statusPaths":["status","data.status"],"resultPaths":["result.video_url","data.result.video_url"],"resultKind":"video"}}]} }`) adapter, err := LoadManifest(manifest) if err != nil { @@ -258,6 +259,33 @@ func TestDeclarativeManifestMapsFieldsAndResponses(t *testing.T) { } } +func TestDeclarativeManifestSupportsMediaPathsTransformsAndErrors(t *testing.T) { + manifest := []byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"declarative-expression-test","version":"1.0.0","name":"Declarative Expression Test","author":"Test","documentation":"# Declarative Expression Test", + "contributes":{"providers":[{"id":"declarative-expression-test","label":"Declarative Expression Test","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/tasks","fields":{"resolution":"request.resolution|lower","ref_image_0":"request.images.0.url","ref_image_1":"request.images.1.url"}},"response":{"errorPaths":["code"],"messagePaths":["msg"]}}]} + }`) + adapter, err := LoadManifest(manifest) + if err != nil { + t.Fatal(err) + } + spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Resolution: "768P", Images: []MediaReference{{URL: "https://cdn.example/one.png"}}}}) + if err != nil { + t.Fatal(err) + } + body := spec.Body.(map[string]any) + if body["resolution"] != "768p" || body["ref_image_0"] != "https://cdn.example/one.png" { + t.Fatalf("mapped body = %#v", body) + } + if _, exists := body["ref_image_1"]; exists { + t.Fatalf("empty media path was not omitted: %#v", body) + } + failed, err := adapter.ParseCreate(context.Background(), []byte(`{"code":"RequestParameterIsWrong","msg":"invalid resolution"}`)) + if err != nil || failed.Status != StatusFailed || failed.Message != "invalid resolution" { + t.Fatalf("failed response = %#v, err = %v", failed, err) + } +} + func TestDeclarativeManifestRequiresDocumentation(t *testing.T) { manifest := Manifest{ APIVersion: "v1", diff --git a/backend/internal/protocol/docs/autodl-comfyui.md b/backend/internal/protocol/docs/autodl-comfyui.md new file mode 100644 index 000000000..d401d709e --- /dev/null +++ b/backend/internal/protocol/docs/autodl-comfyui.md @@ -0,0 +1,28 @@ +# {{NAME}} + +## 接口 + +AutoDL.Art 将 ComfyUI 工作流包装为异步任务接口。创建任务使用 `POST /api/v1/comfyui/comfyui_workflow/{workflow_id}`,影策运行时模板为 `/api/v1/comfyui/comfyui_workflow/{model}`;查询结果使用 `GET /api/v1/comfyui/comfyui_workflow/result/{task_id}`,影策运行时模板为 `/api/v1/comfyui/comfyui_workflow/result/{task_id}`。`workflow_id` 由模型字段传入,不能把它写死在渠道地址中。创建接口只返回任务标识,宿主负责按照统一任务生命周期轮询。 + +## 模型 + +AutoDL 的模型选择对应 ComfyUI 工作流 ID。不同工作流可以声明不同参数,插件清单中的工作流贡献负责描述参数、默认值和画布能力。当前内置示例使用 `minimax_h3_lightx2v_no_pic`,表示 MiniMax H3 文生视频工作流。后续新增工作流只需增加工作流贡献,不需要修改生成服务代码。 + +## 参数 + +请求体为 JSON。常用字段包括 `prompt`、`duration` 和 `resolution`;实际工作流可以扩展自己的参数 schema。状态返回值位于 `data.status`,任务 ID 位于 `data.task_id`,成功结果位于 `data.results`。结果数组中的地址是短期 URL,成功后必须立即由宿主下载到影策资源存储,不能把临时地址直接写入画布长期数据。 + +## 鉴权 + +使用 AutoDL 令牌作为 `Authorization` 请求头。令牌应配置在渠道密钥存储中,只由后端出站请求读取,不进入插件 Manifest、浏览器 URL、日志或持久任务正文。Token 分组应选择 AutoDL 的 ComfyUI 分组。 + +## 状态与结果 + +`QUEUED` 映射为排队,`RUNNING` 映射为处理中,`SUCCESS` 映射为成功,`FAILED` 映射为失败。成功但没有 `data.results` 时,宿主应返回真实错误而不是生成空节点。视频结果由宿主读取 MIME、下载并保存,用户界面只消费已保存资源。 + +## 官方 + +- [AutoDL ComfyUI API](https://autodl.art/docs/comfyui_api/) +- [AutoDL Token 管理](https://autodl.art/large-model/tokens) + +{{CONTRACT}} diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go index 0029829dd..f5403eb09 100644 --- a/backend/internal/protocol/manifest.go +++ b/backend/internal/protocol/manifest.go @@ -10,20 +10,6 @@ import ( "strings" ) -// Manifest is the declarative format used by future protocol packages. It is -// intentionally limited to relative HTTP paths and JSON field mappings; a -// plugin cannot inject code or bypass the host outbound policy. -type Manifest struct { - APIVersion string `json:"apiVersion"` - Metadata Metadata `json:"metadata"` - Create ManifestOperation `json:"create"` - Agent *ManifestOperation `json:"agent,omitempty"` - Poll *ManifestOperation `json:"poll,omitempty"` - Cancel *ManifestOperation `json:"cancel,omitempty"` - Response ManifestResponse `json:"response"` - AgentResponse *ManifestAgentResponse `json:"agentResponse,omitempty"` -} - type ManifestOperation struct { Method string `json:"method"` Path string `json:"path"` @@ -32,13 +18,16 @@ type ManifestOperation struct { } type ManifestResponse struct { - TaskIDPaths []string `json:"taskIdPaths,omitempty"` - StatusPaths []string `json:"statusPaths,omitempty"` - MessagePaths []string `json:"messagePaths,omitempty"` - TextPaths []string `json:"textPaths,omitempty"` - ReasoningPaths []string `json:"reasoningPaths,omitempty"` - ResultURLPaths []string `json:"resultUrlPaths,omitempty"` - ResultKind string `json:"resultKind,omitempty"` + TaskIDPaths []string `json:"taskIdPaths,omitempty"` + StatusPaths []string `json:"statusPaths,omitempty"` + ErrorPaths []string `json:"errorPaths,omitempty"` + MessagePaths []string `json:"messagePaths,omitempty"` + TextPaths []string `json:"textPaths,omitempty"` + ReasoningPaths []string `json:"reasoningPaths,omitempty"` + ResultURLPaths []string `json:"resultUrlPaths,omitempty"` + ResultPaths []string `json:"resultPaths,omitempty"` + ResultKind string `json:"resultKind,omitempty"` + ResultEphemeral bool `json:"resultEphemeral,omitempty"` } // ManifestAgentResponse describes the provider response shape for a @@ -59,25 +48,60 @@ type ManifestAgentResponse struct { type AdapterResolver func(string) (Adapter, bool) func LoadInstalledPlugin(data []byte, resolve AdapterResolver) (Adapter, error) { - var manifest Manifest - if err := json.Unmarshal(data, &manifest); err != nil { - return nil, fmt.Errorf("decode protocol plugin: %w", err) + adapters, err := LoadInstalledProviders(data, resolve) + if err != nil { + return nil, err } - if strings.HasPrefix(strings.TrimSpace(manifest.Metadata.Execution), "host:") { - if resolve == nil { - return nil, fmt.Errorf("protocol plugin %q requires a host execution engine", manifest.Metadata.ID) + if len(adapters) == 0 { + return nil, fmt.Errorf("plugin does not provide an executable provider") + } + return adapters[0], nil +} + +// LoadInstalledProviders loads every provider contribution from one plugin +// package. The package remains the lifecycle and permission unit; providers +// are separate execution entries in the runtime index. +func LoadInstalledProviders(data []byte, resolve AdapterResolver) ([]Adapter, error) { + manifest, err := decodeManifest(data) + if err != nil { + return nil, err + } + if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") { + if len(manifest.Contributes.Providers) != 1 { + return nil, fmt.Errorf("host-backed plugin must declare exactly one provider") } - name := strings.TrimPrefix(strings.TrimSpace(manifest.Metadata.Execution), "host:") + if resolve == nil { + return nil, fmt.Errorf("plugin %q requires a host execution engine", manifest.Metadata.ID) + } + name := strings.TrimPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") adapter, ok := resolve(name) if !ok { - return nil, fmt.Errorf("protocol plugin host execution %q is unavailable", name) + return nil, fmt.Errorf("plugin host execution %q is unavailable", name) } - if err := validatePluginMetadata(manifest.Metadata); err != nil { + if err := ValidateManifest(manifest); err != nil { return nil, err } - return metadataAdapter{metadata: manifest.Metadata, delegate: adapter}, nil + if err := normalizeManifest(&manifest); err != nil { + return nil, err + } + return []Adapter{metadataAdapter{metadata: manifest.Metadata, delegate: adapter}}, nil } - return LoadManifest(data) + if len(manifest.Contributes.Providers) == 0 { + adapter, err := loadDeclarativeManifest(manifest) + if err != nil { + return nil, err + } + return []Adapter{adapter}, nil + } + result := make([]Adapter, 0, len(manifest.Contributes.Providers)) + for index := range manifest.Contributes.Providers { + adapter, err := loadDeclarativeManifestProvider(manifest, index) + if err != nil { + return nil, err + } + result = append(result, adapter) + } + return result, nil } func validatePluginMetadata(metadata Metadata) error { @@ -139,21 +163,42 @@ func (a metadataAdapter) ParseAgent(ctx context.Context, body []byte) (AgentResu } func LoadManifest(data []byte) (Adapter, error) { - var manifest Manifest - if err := json.Unmarshal(data, &manifest); err != nil { - return nil, fmt.Errorf("decode protocol manifest: %w", err) - } - if err := ValidateManifest(manifest); err != nil { + manifest, err := decodeManifest(data) + if err != nil { return nil, err } - if strings.TrimSpace(manifest.Metadata.Execution) == "" { - manifest.Metadata.Execution = "declarative" + return loadDeclarativeManifest(manifest) +} + +func decodeManifest(data []byte) (Manifest, error) { + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return Manifest{}, fmt.Errorf("decode plugin manifest: %w", err) + } + if err := ValidateManifest(manifest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func loadDeclarativeManifest(manifest Manifest) (Adapter, error) { + manifest.Runtime.Backend = "declarative" + if err := normalizeManifest(&manifest); err != nil { + return nil, err + } + return manifestAdapter{manifest: manifest}, nil +} + +func loadDeclarativeManifestProvider(manifest Manifest, index int) (Adapter, error) { + manifest.Runtime.Backend = "declarative" + if err := normalizeManifestForProvider(&manifest, index); err != nil { + return nil, err } return manifestAdapter{manifest: manifest}, nil } func ValidateManifest(manifest Manifest) error { - if strings.TrimSpace(manifest.APIVersion) != "v1" { + if strings.TrimSpace(manifest.APIVersion) != "yingce.plugin/v1" { return fmt.Errorf("unsupported protocol manifest apiVersion %q", manifest.APIVersion) } if strings.TrimSpace(manifest.Metadata.ID) == "" || strings.TrimSpace(manifest.Metadata.Version) == "" { @@ -162,54 +207,113 @@ func ValidateManifest(manifest Manifest) error { if strings.TrimSpace(manifest.Metadata.Name) == "" { return fmt.Errorf("protocol manifest metadata requires name") } - if strings.TrimSpace(manifest.Metadata.Documentation) == "" { - return fmt.Errorf("protocol manifest metadata requires Markdown documentation") - } if !validManifestIdentifier(manifest.Metadata.ID) { return fmt.Errorf("protocol manifest metadata id is invalid") } if len(manifest.Metadata.Name) > 160 || len(manifest.Metadata.Vendor) > 120 { return fmt.Errorf("protocol manifest metadata name or vendor is too long") } - if len(manifest.Metadata.Categories) == 0 || len(manifest.Metadata.Scopes) == 0 { - return fmt.Errorf("protocol manifest metadata requires categories and scopes") + if len(manifest.Contributes.Providers) == 0 && !hasNonProviderContribution(manifest.Contributes) { + return fmt.Errorf("plugin must declare at least one contribution") } - for _, capability := range manifest.Metadata.Categories { + if len(manifest.Contributes.Providers) == 0 { + return nil + } + provider := manifest.Contributes.Providers[0] + if strings.TrimSpace(provider.ID) == "" || strings.TrimSpace(provider.Label) == "" { + return fmt.Errorf("provider contribution requires id and label") + } + if len(provider.Capabilities) == 0 || len(provider.Scopes) == 0 { + return fmt.Errorf("provider contribution requires capabilities and scopes") + } + for _, capability := range provider.Capabilities { if capability != CapabilityText && capability != CapabilityImage && capability != CapabilityVideo && capability != CapabilityAudio { return fmt.Errorf("unsupported protocol capability %q", capability) } } - for _, scope := range manifest.Metadata.Scopes { + for _, scope := range provider.Scopes { switch scope { case SurfaceAdminSystemChannel, SurfaceUserCustomChannel, SurfaceCanvas, SurfaceCreation, SurfaceAgent: default: return fmt.Errorf("unsupported protocol scope %q", scope) } } - if err := validateManifestOperation(manifest.Create); err != nil { + if err := validateManifestOperation(provider.Create); err != nil { return fmt.Errorf("create operation: %w", err) } - if manifest.Agent != nil { - if err := validateManifestOperation(*manifest.Agent); err != nil { + if provider.Agent != nil { + if err := validateManifestOperation(*provider.Agent); err != nil { return fmt.Errorf("agent operation: %w", err) } - if manifest.AgentResponse == nil { + if provider.AgentResponse == nil { return fmt.Errorf("agent response mapping is required when agent operation is declared") } } - if manifest.Poll != nil { - if err := validateManifestOperation(*manifest.Poll); err != nil { + if provider.Poll != nil { + if err := validateManifestOperation(*provider.Poll); err != nil { return fmt.Errorf("poll operation: %w", err) } } - if manifest.Cancel != nil { - if err := validateManifestOperation(*manifest.Cancel); err != nil { + if provider.Cancel != nil { + if err := validateManifestOperation(*provider.Cancel); err != nil { return fmt.Errorf("cancel operation: %w", err) } } return nil } +func normalizeManifest(manifest *Manifest) error { + if manifest == nil { + return fmt.Errorf("plugin manifest is missing") + } + if len(manifest.Contributes.Providers) == 0 { + manifest.Metadata.Execution = manifest.Runtime.Backend + return nil + } + return normalizeManifestForProvider(manifest, 0) +} + +func normalizeManifestForProvider(manifest *Manifest, index int) error { + if manifest == nil || index < 0 || index >= len(manifest.Contributes.Providers) { + return fmt.Errorf("plugin provider contribution is missing") + } + provider := manifest.Contributes.Providers[index] + manifest.Metadata.ID = provider.ID + manifest.Metadata.Categories = provider.Capabilities + manifest.Metadata.Scopes = provider.Scopes + manifest.Metadata.Parameters = provider.Parameters + manifest.Metadata.Create = operationSummary(provider.Create) + manifest.Metadata.Poll = operationSummaryPtr(provider.Poll) + manifest.Metadata.Cancel = operationSummaryPtr(provider.Cancel) + manifest.Metadata.ContentType = provider.Create.ContentType + manifest.Metadata.RequiresPublicMediaURLs = provider.RequiresPublicMediaURLs + manifest.Metadata.Execution = manifest.Runtime.Backend + manifest.Create = provider.Create + manifest.Agent = provider.Agent + manifest.Poll = provider.Poll + manifest.Cancel = provider.Cancel + manifest.Response = provider.Response + manifest.AgentResponse = provider.AgentResponse + return nil +} + +func hasNonProviderContribution(contributes ManifestContributions) bool { + return len(contributes.Workflows) > 0 || len(contributes.CanvasNodes) > 0 || len(contributes.Transforms) > 0 || len(contributes.Commands) > 0 || len(contributes.AssetSources) > 0 || len(contributes.UsageObservers) > 0 || len(contributes.AICapabilities) > 0 || len(contributes.Agents) > 0 || len(contributes.ImportExport) > 0 +} + +func operationSummary(operation ManifestOperation) string { + path := strings.ReplaceAll(operation.Path, "{{model}}", "{model}") + path = strings.ReplaceAll(path, "{{taskId}}", "{task_id}") + return strings.ToUpper(operation.Method) + " " + path +} + +func operationSummaryPtr(operation *ManifestOperation) string { + if operation == nil { + return "" + } + return operationSummary(*operation) +} + func validateManifestOperation(operation ManifestOperation) error { method := strings.ToUpper(strings.TrimSpace(operation.Method)) if method != http.MethodGet && method != http.MethodPost && method != http.MethodDelete && method != http.MethodPut { @@ -225,6 +329,9 @@ type manifestAdapter struct{ manifest Manifest } func (a manifestAdapter) Metadata() Metadata { return a.manifest.Metadata } func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) { + if len(a.manifest.Contributes.Providers) == 0 { + return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID) + } return buildManifestOperation(a.manifest.Create, c.Request, ""), nil } func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) { @@ -302,13 +409,18 @@ func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResu status = StatusPending } message := firstPathValue(payload, a.manifest.Response.MessagePaths...) + if manifestError(payload, a.manifest.Response.ErrorPaths...) { + status = StatusFailed + } result := &Result{ Text: firstPathValue(payload, a.manifest.Response.TextPaths...), Reasoning: firstPathValue(payload, a.manifest.Response.ReasoningPaths...), } - for _, path := range a.manifest.Response.ResultURLPaths { - if value := firstPathValue(payload, path); value != "" { - item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind} + paths := append([]string{}, a.manifest.Response.ResultURLPaths...) + paths = append(paths, a.manifest.Response.ResultPaths...) + for _, path := range paths { + for _, value := range mediaPathValues(payload, path) { + item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind, Ephemeral: a.manifest.Response.ResultEphemeral} switch a.manifest.Response.ResultKind { case "image": result.Images = append(result.Images, item) @@ -328,13 +440,42 @@ func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResu return CreateResult{TaskID: id, Status: status, Result: result, Message: message} } +func mediaPathValues(payload map[string]any, path string) []string { + value := pathValue(payload, path) + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + return []string{strings.TrimSpace(text)} + } + items, ok := value.([]any) + if !ok { + return nil + } + values := make([]string, 0, len(items)) + for _, item := range items { + switch typed := item.(type) { + case string: + if strings.TrimSpace(typed) != "" { + values = append(values, strings.TrimSpace(typed)) + } + case map[string]any: + if url := firstString(typed, "url", "file_url", "fileUrl", "video_url", "videoUrl", "image_url", "imageUrl"); url != "" { + values = append(values, url) + } + } + } + return values +} + func buildManifestOperation(operation ManifestOperation, request GenerationRequest, taskID string) RequestSpec { var body map[string]any if len(operation.Fields) > 0 { body = make(map[string]any, len(operation.Fields)) } for key, expression := range operation.Fields { - setMapPath(body, key, manifestExpressionValue(expression, request, taskID)) + value := manifestExpressionValue(expression, request, taskID) + if value == nil { + continue + } + setMapPath(body, key, value) } path := strings.ReplaceAll(operation.Path, "{{taskId}}", url.PathEscape(taskID)) path = strings.ReplaceAll(path, "{{model}}", url.PathEscape(request.Model)) @@ -343,40 +484,79 @@ func buildManifestOperation(operation ManifestOperation, request GenerationReque func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any { expression = strings.TrimSpace(expression) - switch expression { - case "request.model": - return request.Model - case "request.prompt": - return request.Prompt - case "request.images": - return request.Images - case "request.videos": - return request.Videos - case "request.audios": - return request.Audios - case "request.imageCount": - return request.ImageCount - case "request.duration": - return request.Duration - case "request.aspectRatio": - return request.AspectRatio - case "request.resolution": - return request.Resolution - case "request.quality": - return request.Quality - case "request.generateAudio": - return request.GenerateAudio - case "request.watermark": - return request.Watermark - case "request.operation": - return request.Operation - case "taskId": - return taskID + source := expression + transforms := []string{} + if separator := strings.IndexByte(expression, '|'); separator >= 0 { + source = strings.TrimSpace(expression[:separator]) + transforms = strings.Split(expression[separator+1:], "|") } - if strings.HasPrefix(expression, "request.extra.") { - return pathValue(request.Extra, strings.TrimPrefix(expression, "request.extra.")) + + var value any + switch { + case source == "taskId": + value = taskID + case strings.HasPrefix(source, "request."): + value = pathValue(manifestRequestValues(request), strings.TrimPrefix(source, "request.")) + default: + value = source + } + for _, transform := range transforms { + value = applyManifestTransform(value, transform) + } + return value +} + +// manifestRequestValues is deliberately a small, JSON-shaped view of the +// platform request. It lets uploaded manifests address media items by index +// without exposing Go structs or adding provider-specific host code. +func manifestRequestValues(request GenerationRequest) map[string]any { + return map[string]any{ + "model": request.Model, + "prompt": request.Prompt, + "images": manifestMediaValues(request.Images), + "videos": manifestMediaValues(request.Videos), + "audios": manifestMediaValues(request.Audios), + "imageCount": request.ImageCount, + "duration": request.Duration, + "aspectRatio": request.AspectRatio, + "resolution": request.Resolution, + "quality": request.Quality, + "generateAudio": request.GenerateAudio, + "watermark": request.Watermark, + "operation": request.Operation, + "extra": request.Extra, + } +} + +func manifestMediaValues(values []MediaReference) []any { + result := make([]any, 0, len(values)) + for _, value := range values { + result = append(result, map[string]any{ + "url": value.URL, + "dataUrl": value.DataURL, + "kind": value.Kind, + "ephemeral": value.Ephemeral, + }) + } + return result +} + +func applyManifestTransform(value any, transform string) any { + transform = strings.ToLower(strings.TrimSpace(transform)) + text, ok := value.(string) + if !ok { + return value + } + switch transform { + case "trim": + return strings.TrimSpace(text) + case "lower", "lowercase": + return strings.ToLower(text) + case "upper", "uppercase": + return strings.ToUpper(text) + default: + return value } - return expression } func pathValue(payload map[string]any, path string) any { @@ -413,6 +593,73 @@ func firstPathValue(payload map[string]any, paths ...string) string { return "" } +func manifestError(payload map[string]any, paths ...string) bool { + for _, path := range paths { + value := pathValue(payload, path) + switch typed := value.(type) { + case nil: + continue + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "", "0", "ok", "success", "succeeded", "true": + continue + default: + return true + } + case float64: + if typed == 0 { + continue + } + return true + case float32: + if typed == 0 { + continue + } + return true + case int, int8, int16, int32, int64: + if reflectValueIsZero(typed) { + continue + } + return true + case uint, uint8, uint16, uint32, uint64: + if reflectValueIsZero(typed) { + continue + } + return true + default: + return true + } + } + return false +} + +func reflectValueIsZero(value any) bool { + switch typed := value.(type) { + case int: + return typed == 0 + case int8: + return typed == 0 + case int16: + return typed == 0 + case int32: + return typed == 0 + case int64: + return typed == 0 + case uint: + return typed == 0 + case uint8: + return typed == 0 + case uint16: + return typed == 0 + case uint32: + return typed == 0 + case uint64: + return typed == 0 + default: + return false + } +} + // firstPathJSONValue keeps scalar response fields string-like while allowing // providers to return tool arguments as an object instead of a JSON string. // The platform tool contract always stores arguments as JSON text. diff --git a/backend/internal/protocol/manifest_agent_test.go b/backend/internal/protocol/manifest_agent_test.go index 7425709d0..53b95a9b7 100644 --- a/backend/internal/protocol/manifest_agent_test.go +++ b/backend/internal/protocol/manifest_agent_test.go @@ -8,12 +8,9 @@ import ( func TestManifestAgentMapsRequestAndResponse(t *testing.T) { manifest := []byte(`{ - "apiVersion":"v1", - "metadata":{"id":"agent-test","version":"1.0.0","name":"Agent Test","vendor":"Test","categories":["text"],"scopes":["agent"],"documentation":"# Agent Test"}, - "create":{"method":"POST","path":"/create"}, - "agent":{"method":"POST","path":"/messages","fields":{"model":"request.model","messages":"request.extra.agent.chatCompletion.messages","tools":"request.extra.agent.chatCompletion.tools","tool_choice":"request.extra.agent.chatCompletion.tool_choice"}}, - "agentResponse":{"textPaths":["choices.0.message.content"],"toolCallsPath":"choices.0.message.tool_calls","toolCallIdPaths":["id"],"toolCallNamePaths":["function.name"],"toolCallArgumentsPaths":["function.arguments"]}, - "response":{} + "apiVersion":"yingce.plugin/v1", + "id":"agent-test","version":"1.0.0","name":"Agent Test","author":"Test","documentation":"# Agent Test", + "contributes":{"providers":[{"id":"agent-test","label":"Agent Test","capabilities":["text"],"scopes":["agent"],"create":{"method":"POST","path":"/create"},"agent":{"method":"POST","path":"/messages","fields":{"model":"request.model","messages":"request.extra.agent.chatCompletion.messages","tools":"request.extra.agent.chatCompletion.tools","tool_choice":"request.extra.agent.chatCompletion.tool_choice"}},"agentResponse":{"textPaths":["choices.0.message.content"],"toolCallsPath":"choices.0.message.tool_calls","toolCallIdPaths":["id"],"toolCallNamePaths":["function.name"],"toolCallArgumentsPaths":["function.arguments"]},"response":{}}]} }`) adapter, err := LoadManifest(manifest) if err != nil { @@ -64,10 +61,9 @@ func TestManifestAgentMapsRequestAndResponse(t *testing.T) { func TestManifestMapsSynchronousTextResponse(t *testing.T) { manifest := []byte(`{ - "apiVersion":"v1", - "metadata":{"id":"text-test","version":"1.0.0","name":"Text Test","vendor":"Test","categories":["text"],"scopes":["canvas"],"documentation":"# Text Test"}, - "create":{"method":"POST","path":"/chat","fields":{"prompt":"request.prompt"}}, - "response":{"textPaths":["choices.0.message.content"],"reasoningPaths":["choices.0.message.reasoning_content"]} + "apiVersion":"yingce.plugin/v1", + "id":"text-test","version":"1.0.0","name":"Text Test","author":"Test","documentation":"# Text Test", + "contributes":{"providers":[{"id":"text-test","label":"Text Test","capabilities":["text"],"scopes":["canvas"],"create":{"method":"POST","path":"/chat","fields":{"prompt":"request.prompt"}},"response":{"textPaths":["choices.0.message.content"],"reasoningPaths":["choices.0.message.reasoning_content"]}}]} }`) adapter, err := LoadManifest(manifest) if err != nil { @@ -85,3 +81,19 @@ func TestManifestMapsSynchronousTextResponse(t *testing.T) { t.Fatalf("text result = %#v", result) } } + +func TestManifestUsesOnlyUnifiedTopLevelContract(t *testing.T) { + legacy := []byte(`{"apiVersion":"v1","metadata":{"id":"legacy","version":"1","name":"Legacy"},"create":{"method":"POST","path":"/tasks"},"response":{}}`) + if _, err := LoadManifest(legacy); err == nil { + t.Fatal("legacy protocol manifest was accepted") + } + canvasOnly := []byte(`{"apiVersion":"yingce.plugin/v1","id":"canvas-extension","name":"Canvas Extension","version":"1.0.0","contributes":{"canvasNodes":[{"id":"canvas-extension/node","label":"Node","defaultTitle":"Node","defaultSize":{"width":320,"height":200},"schema":{},"renderer":"declarative"}]}}`) + if _, err := LoadManifest(canvasOnly); err != nil { + t.Fatalf("providerless unified plugin was rejected: %v", err) + } + multi := []byte(`{"apiVersion":"yingce.plugin/v1","id":"multi-provider","name":"Multi Provider","version":"1.0.0","contributes":{"providers":[{"id":"multi-text","label":"Text","capabilities":["text"],"scopes":["canvas"],"create":{"method":"POST","path":"/text"},"response":{}},{"id":"multi-image","label":"Image","capabilities":["image"],"scopes":["canvas"],"create":{"method":"POST","path":"/image"},"response":{}}]}}`) + adapters, err := LoadInstalledProviders(multi, nil) + if err != nil || len(adapters) != 2 || adapters[0].Metadata().ID != "multi-text" || adapters[1].Metadata().ID != "multi-image" { + t.Fatalf("multi-provider load = %#v, %v", adapters, err) + } +} diff --git a/backend/internal/protocol/package.go b/backend/internal/protocol/package.go new file mode 100644 index 000000000..3594a2ee5 --- /dev/null +++ b/backend/internal/protocol/package.go @@ -0,0 +1,136 @@ +package protocol + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path" + "strings" +) + +const ( + PluginPackageFormat = "yingce.plugin/package-v1" + PluginPackageMaxBytes = 16 << 20 + PluginManifestMaxBytes = 512 << 10 + pluginPackageMaxFiles = 256 + pluginPackageMaxEntry = 8 << 20 +) + +// PluginPackage is the transport envelope for every uploaded plugin. The +// manifest remains the single capability contract; files are optional runtime +// artifacts addressed by manifest.entry. +type PluginPackage struct { + Manifest Manifest + ManifestRaw []byte + Files map[string][]byte +} + +// ParsePluginPackage validates the package container and returns its manifest +// and files. Uploaded code is never executed by this function. +func ParsePluginPackage(data []byte) (PluginPackage, error) { + if len(data) == 0 || len(data) > PluginPackageMaxBytes { + return PluginPackage{}, fmt.Errorf("plugin package must be between 1 and %d bytes", PluginPackageMaxBytes) + } + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return PluginPackage{}, fmt.Errorf("decode plugin package: %w", err) + } + if len(reader.File) == 0 || len(reader.File) > pluginPackageMaxFiles { + return PluginPackage{}, fmt.Errorf("plugin package must contain between 1 and %d files", pluginPackageMaxFiles) + } + files := make(map[string][]byte, len(reader.File)) + var manifestRaw []byte + for _, file := range reader.File { + name, err := validatePluginPackagePath(file.Name) + if err != nil { + return PluginPackage{}, err + } + if _, exists := files[name]; exists { + return PluginPackage{}, fmt.Errorf("duplicate plugin package file %q", name) + } + if file.FileInfo().IsDir() { + continue + } + if file.Mode()&os.ModeSymlink != 0 { + return PluginPackage{}, fmt.Errorf("plugin package cannot contain symlink %q", name) + } + if file.UncompressedSize64 > pluginPackageMaxEntry { + return PluginPackage{}, fmt.Errorf("plugin package file %q exceeds %d bytes", name, pluginPackageMaxEntry) + } + stream, err := file.Open() + if err != nil { + return PluginPackage{}, fmt.Errorf("open plugin package file %q: %w", name, err) + } + content, readErr := io.ReadAll(io.LimitReader(stream, pluginPackageMaxEntry+1)) + closeErr := stream.Close() + if readErr != nil { + return PluginPackage{}, fmt.Errorf("read plugin package file %q: %w", name, readErr) + } + if closeErr != nil { + return PluginPackage{}, fmt.Errorf("close plugin package file %q: %w", name, closeErr) + } + if len(content) > pluginPackageMaxEntry { + return PluginPackage{}, fmt.Errorf("plugin package file %q exceeds %d bytes", name, pluginPackageMaxEntry) + } + files[name] = content + if name == "manifest.json" { + manifestRaw = content + } + } + if len(manifestRaw) == 0 { + return PluginPackage{}, fmt.Errorf("plugin package must contain manifest.json") + } + if len(manifestRaw) > PluginManifestMaxBytes { + return PluginPackage{}, fmt.Errorf("manifest.json exceeds %d bytes", PluginManifestMaxBytes) + } + var manifest Manifest + if err := json.Unmarshal(manifestRaw, &manifest); err != nil { + return PluginPackage{}, fmt.Errorf("decode plugin manifest: %w", err) + } + if err := ValidateManifest(manifest); err != nil { + return PluginPackage{}, err + } + if err := validatePluginPackageRuntime(manifest, files); err != nil { + return PluginPackage{}, err + } + return PluginPackage{Manifest: manifest, ManifestRaw: manifestRaw, Files: files}, nil +} + +func validatePluginPackagePath(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" || strings.ContainsRune(name, '\\') || strings.HasPrefix(name, "/") || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." { + return "", fmt.Errorf("invalid plugin package path %q", name) + } + if name == "manifest.json" || strings.HasPrefix(name, "web/") || strings.HasPrefix(name, "assets/") || strings.HasPrefix(name, "docs/") || name == "README.md" || name == "LICENSE" { + return name, nil + } + return "", fmt.Errorf("plugin package path %q is outside the allowed package roots", name) +} + +func validatePluginPackageRuntime(manifest Manifest, files map[string][]byte) error { + if strings.TrimSpace(manifest.Entry) == "" { + if strings.TrimSpace(manifest.Runtime.Web) != "" { + return fmt.Errorf("manifest.runtime.web requires manifest.entry") + } + for name := range files { + if strings.HasPrefix(name, "web/") { + return fmt.Errorf("plugin package web files require manifest.entry") + } + } + return nil + } + entry := strings.TrimSpace(manifest.Entry) + if !strings.HasPrefix(entry, "web/") || path.Clean(entry) != entry { + return fmt.Errorf("plugin manifest entry must point inside web/") + } + if _, ok := files[entry]; !ok { + return fmt.Errorf("plugin manifest entry %q is missing from package", entry) + } + if manifest.Runtime.Web != "sandbox" && manifest.Runtime.Web != "worker" { + return fmt.Errorf("uploaded web plugins require runtime.web sandbox or worker") + } + return nil +} diff --git a/backend/internal/protocol/package_test.go b/backend/internal/protocol/package_test.go new file mode 100644 index 000000000..252ececba --- /dev/null +++ b/backend/internal/protocol/package_test.go @@ -0,0 +1,62 @@ +package protocol + +import ( + "archive/zip" + "bytes" + "testing" +) + +func TestParsePluginPackageRejectsBareManifest(t *testing.T) { + if _, err := ParsePluginPackage([]byte(`{"apiVersion":"yingce.plugin/v1"}`)); err == nil { + t.Fatal("bare JSON manifest was accepted as a plugin package") + } +} + +func TestParsePluginPackageValidatesWebEntry(t *testing.T) { + manifest := []byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"ui-extension", + "name":"UI Extension", + "version":"1.0.0", + "entry":"web/entry.js", + "surfaces":["fullscreen"], + "runtime":{"web":"sandbox"}, + "contributes":{"commands":[{"id":"ui-extension/open","label":"Open UI"}]} + }`) + pkg, err := ParsePluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "web/entry.js": []byte("self.postMessage({type:'ready'})")})) + if err != nil { + t.Fatal(err) + } + if pkg.Manifest.Entry != "web/entry.js" || len(pkg.Files["web/entry.js"]) == 0 { + t.Fatalf("package = %#v", pkg) + } + + missingEntry := zipPluginPackage(t, map[string][]byte{"manifest.json": manifest}) + if _, err := ParsePluginPackage(missingEntry); err == nil { + t.Fatal("missing web entry was accepted") + } + + forbidden := zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "web/entry.js": []byte("ready"), "server/entry.js": []byte("unsafe")}) + if _, err := ParsePluginPackage(forbidden); err == nil { + t.Fatal("forbidden package path was accepted") + } +} + +func zipPluginPackage(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, data := range files { + file, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(data); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go index ac7c98042..e7f6ebe2f 100644 --- a/backend/internal/protocol/types.go +++ b/backend/internal/protocol/types.go @@ -1,6 +1,9 @@ package protocol -import "fmt" +import ( + "encoding/json" + "fmt" +) // Capability is the kind of model operation handled by a protocol adapter. type Capability string @@ -34,9 +37,10 @@ const ( ) type MediaReference struct { - URL string `json:"url,omitempty"` - DataURL string `json:"dataUrl,omitempty"` - Kind string `json:"kind,omitempty"` + URL string `json:"url,omitempty"` + DataURL string `json:"dataUrl,omitempty"` + Kind string `json:"kind,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` } // GenerationRequest is the platform contract shared by canvas, creation and agent. @@ -112,24 +116,188 @@ type Parameter struct { } type Metadata struct { - ID string `json:"id"` - Version string `json:"version"` - Name string `json:"name"` - Vendor string `json:"vendor"` - Description string `json:"description,omitempty"` - Categories []Capability `json:"categories"` - Scopes []Surface `json:"scopes"` - Parameters []Parameter `json:"parameters,omitempty"` - Create string `json:"create,omitempty"` - Poll string `json:"poll,omitempty"` - Cancel string `json:"cancel,omitempty"` - ContentType string `json:"contentType,omitempty"` - Documentation string `json:"documentation,omitempty"` - LegacyAliases []string `json:"legacyAliases,omitempty"` - Enabled bool `json:"enabled"` - Installable bool `json:"installable"` - Execution string `json:"execution,omitempty"` - UnavailableReason string `json:"unavailableReason,omitempty"` + ID string `json:"id"` + Version string `json:"version"` + Name string `json:"name"` + Vendor string `json:"vendor"` + Description string `json:"description,omitempty"` + Categories []Capability `json:"categories"` + Scopes []Surface `json:"scopes"` + Parameters []Parameter `json:"parameters,omitempty"` + Create string `json:"-"` + Poll string `json:"-"` + Cancel string `json:"-"` + ContentType string `json:"-"` + Documentation string `json:"documentation,omitempty"` + LegacyAliases []string `json:"-"` + Enabled bool `json:"enabled"` + Installable bool `json:"installable"` + Execution string `json:"-"` + RequiresPublicMediaURLs bool `json:"-"` + UnavailableReason string `json:"-"` +} + +// Manifest is the single plugin contract. A plugin may contribute providers, +// workflows, canvas nodes and other capabilities from the same package; the +// runtime chooses an execution engine per contribution without changing the +// public manifest shape. +type Manifest struct { + APIVersion string `json:"apiVersion"` + Metadata Metadata `json:"-"` + Entry string `json:"entry,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Runtime ManifestRuntime `json:"runtime,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Configuration ManifestConfiguration `json:"configuration,omitempty"` + Contributes ManifestContributions `json:"contributes"` + // These fields are the normalized provider projection used by the runtime. + // They are never serialized; provider execution is described only under + // contributes.providers. + Create ManifestOperation `json:"-"` + Agent *ManifestOperation `json:"-"` + Poll *ManifestOperation `json:"-"` + Cancel *ManifestOperation `json:"-"` + Response ManifestResponse `json:"-"` + AgentResponse *ManifestAgentResponse `json:"-"` +} + +// Manifest JSON is the public plugin contract. Metadata is an internal +// execution projection and is deliberately not a second wire format. +func (m Manifest) MarshalJSON() ([]byte, error) { + type wire struct { + APIVersion string `json:"apiVersion"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + Documentation string `json:"documentation,omitempty"` + Entry string `json:"entry,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Enabled bool `json:"enabled"` + Installable bool `json:"installable,omitempty"` + Runtime ManifestRuntime `json:"runtime,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Configuration ManifestConfiguration `json:"configuration,omitempty"` + Contributes ManifestContributions `json:"contributes"` + } + return json.Marshal(wire{APIVersion: m.APIVersion, ID: m.Metadata.ID, Name: m.Metadata.Name, Version: m.Metadata.Version, Author: m.Metadata.Vendor, Description: m.Metadata.Description, Documentation: m.Metadata.Documentation, Entry: m.Entry, Surfaces: m.Surfaces, Enabled: m.Metadata.Enabled, Installable: m.Metadata.Installable, Runtime: m.Runtime, Permissions: m.Permissions, Configuration: m.Configuration, Contributes: m.Contributes}) +} + +func (m *Manifest) UnmarshalJSON(data []byte) error { + type wire struct { + APIVersion string `json:"apiVersion"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Documentation string `json:"documentation"` + Entry string `json:"entry"` + Surfaces []string `json:"surfaces"` + Enabled *bool `json:"enabled"` + Installable bool `json:"installable"` + Runtime ManifestRuntime `json:"runtime"` + Permissions []string `json:"permissions"` + Configuration ManifestConfiguration `json:"configuration"` + Contributes ManifestContributions `json:"contributes"` + } + var value wire + if err := json.Unmarshal(data, &value); err != nil { + return err + } + enabled := true + if value.Enabled != nil { + enabled = *value.Enabled + } + m.APIVersion, m.Entry, m.Surfaces, m.Runtime, m.Permissions, m.Configuration, m.Contributes = value.APIVersion, value.Entry, value.Surfaces, value.Runtime, value.Permissions, value.Configuration, value.Contributes + m.Metadata = Metadata{ID: value.ID, Name: value.Name, Version: value.Version, Vendor: value.Author, Description: value.Description, Documentation: value.Documentation, Enabled: enabled, Installable: value.Installable} + return nil +} + +type ManifestRuntime struct { + Backend string `json:"backend,omitempty"` + Web string `json:"web,omitempty"` +} + +type ManifestConfiguration struct { + Fields []ManifestField `json:"fields,omitempty"` +} + +type ManifestField struct { + Name string `json:"name"` + Type string `json:"type"` + Label string `json:"label,omitempty"` + Required bool `json:"required,omitempty"` + Default any `json:"default,omitempty"` + Description string `json:"description,omitempty"` + Values []string `json:"values,omitempty"` +} + +type ManifestContributions struct { + Providers []ManifestProvider `json:"providers,omitempty"` + Workflows []ManifestWorkflow `json:"workflows,omitempty"` + CanvasNodes []ManifestCanvasNode `json:"canvasNodes,omitempty"` + Transforms []ManifestTransform `json:"transforms,omitempty"` + Commands []ManifestCommand `json:"commands,omitempty"` + AssetSources []string `json:"assetSources,omitempty"` + UsageObservers []string `json:"usageObservers,omitempty"` + AICapabilities []string `json:"aiCapabilities,omitempty"` + Agents []string `json:"agents,omitempty"` + ImportExport []string `json:"importExport,omitempty"` +} + +type ManifestProvider struct { + ID string `json:"id"` + Label string `json:"label"` + Capabilities []Capability `json:"capabilities"` + Scopes []Surface `json:"scopes"` + BaseURL string `json:"baseUrl,omitempty"` + RequiresPublicMediaURLs bool `json:"requiresPublicMediaUrls,omitempty"` + Auth ManifestAuth `json:"auth,omitempty"` + Parameters []Parameter `json:"parameters,omitempty"` + Create ManifestOperation `json:"create"` + Agent *ManifestOperation `json:"agent,omitempty"` + Poll *ManifestOperation `json:"poll,omitempty"` + Cancel *ManifestOperation `json:"cancel,omitempty"` + Response ManifestResponse `json:"response"` + AgentResponse *ManifestAgentResponse `json:"agentResponse,omitempty"` +} + +type ManifestAuth struct { + Type string `json:"type"` + Field string `json:"field"` + Header string `json:"header,omitempty"` +} + +type ManifestWorkflow struct { + ID string `json:"id"` + Label string `json:"label"` + ProviderID string `json:"providerId"` + Capability Capability `json:"capability"` + Parameters []Parameter `json:"parameters"` + Defaults map[string]any `json:"defaults,omitempty"` +} + +type ManifestCanvasNode struct { + ID string `json:"id"` + Label string `json:"label"` + DefaultTitle string `json:"defaultTitle"` + DefaultSize map[string]int `json:"defaultSize"` + Schema map[string]any `json:"schema"` + Renderer string `json:"renderer"` +} + +type ManifestTransform struct { + ID string `json:"id"` + Input string `json:"input"` + Output string `json:"output"` + Runtime string `json:"runtime"` +} + +type ManifestCommand struct { + ID string `json:"id"` + Label string `json:"label"` } func (r RequestSpec) Validate() error { diff --git a/backend/internal/service/protocol_plugins.go b/backend/internal/service/protocol_plugins.go index b00423073..44de17b05 100644 --- a/backend/internal/service/protocol_plugins.go +++ b/backend/internal/service/protocol_plugins.go @@ -17,7 +17,7 @@ import ( "infinite-canvas/backend/internal/protocol" ) -const protocolPluginMaxBytes = 512 << 10 +const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes // PluginView is the backend representation consumed by the single frontend // plugin center. Protocol-specific runtime data is nested under Protocol so @@ -26,6 +26,7 @@ type PluginView struct { Manifest PluginManifestView `json:"manifest"` Source string `json:"source"` FileName string `json:"fileName"` + Package string `json:"package"` SHA256 string `json:"sha256"` InstalledAt time.Time `json:"installedAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -34,57 +35,65 @@ type PluginView struct { } type PluginManifestView struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - APIVersion string `json:"apiVersion"` - Category string `json:"category"` - Description string `json:"description,omitempty"` - Author string `json:"author,omitempty"` - Surfaces []string `json:"surfaces"` - Permissions []string `json:"permissions"` - Trusted bool `json:"trusted"` - Kind string `json:"kind"` - Protocol PluginProtocolView `json:"protocol"` -} - -type PluginProtocolView struct { - Categories []string `json:"categories"` - Scopes []string `json:"scopes"` - Create string `json:"create,omitempty"` - Poll string `json:"poll,omitempty"` - Cancel string `json:"cancel,omitempty"` - ContentType string `json:"contentType,omitempty"` - Documentation string `json:"documentation,omitempty"` - Parameters []protocol.Parameter `json:"parameters,omitempty"` + APIVersion string `json:"apiVersion"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Entry string `json:"entry,omitempty"` + Surfaces []string `json:"surfaces,omitempty"` + Description string `json:"description,omitempty"` + Documentation string `json:"documentation,omitempty"` + Author string `json:"author,omitempty"` + Permissions []string `json:"permissions"` + Trusted bool `json:"trusted"` + Runtime protocol.ManifestRuntime `json:"runtime,omitempty"` + Configuration protocol.ManifestConfiguration `json:"configuration,omitempty"` + Contributes protocol.ManifestContributions `json:"contributes"` } type pluginRecord struct { - Raw []byte - Metadata protocol.Metadata - Source string - FileName string - SHA256 string - InstalledAt time.Time - UpdatedAt time.Time - Status string - Error string + Raw []byte + Metadata protocol.Metadata + Source string + FileName string + PackagePath string + PackageSHA256 string + SHA256 string + InstalledAt time.Time + UpdatedAt time.Time + Status string + Error string } type pluginRuntime struct { - mu sync.RWMutex - mutationMu sync.Mutex - dir string - plugins map[string]pluginRecord - registry *protocol.Registry + mu sync.RWMutex + mutationMu sync.Mutex + registryPath string + packageDir string + plugins map[string]pluginRecord + registry *protocol.Registry +} + +type pluginRegistryRecord struct { + ID string `json:"id"` + Raw json.RawMessage `json:"manifest"` + Source string `json:"source"` + FileName string `json:"fileName,omitempty"` + PackagePath string `json:"packagePath,omitempty"` + PackageSHA256 string `json:"packageSha256,omitempty"` + InstalledAt time.Time `json:"installedAt"` + UpdatedAt time.Time `json:"updatedAt"` } func newPluginRuntime(dataDir string) (*pluginRuntime, error) { - dir := filepath.Join(dataDir, "plugins") - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("create plugin directory: %w", err) + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("create plugin registry directory: %w", err) } - center := &pluginRuntime{dir: dir, plugins: make(map[string]pluginRecord)} + packageDir := filepath.Join(dataDir, "plugin-packages") + if err := os.MkdirAll(packageDir, 0o700); err != nil { + return nil, fmt.Errorf("create plugin package directory: %w", err) + } + center := &pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json"), packageDir: packageDir, plugins: make(map[string]pluginRecord)} if err := center.bootstrapBundledPlugins(); err != nil { return nil, err } @@ -95,92 +104,122 @@ func newPluginRuntime(dataDir string) (*pluginRuntime, error) { } func (c *pluginRuntime) bootstrapBundledPlugins() error { + stored, err := c.readRegistry() + if err != nil { + return err + } + byID := make(map[string]pluginRegistryRecord, len(stored)) + for _, record := range stored { + byID[record.ID] = record + } items := protocol.Builtins().List("", "", true) + bundledIDs := make(map[string]struct{}, len(items)) for _, metadata := range items { + bundledIDs[metadata.ID] = struct{}{} protocol.AttachDocumentation(&metadata) - metadata.Execution = "host:" + metadata.ID metadata.Installable = true - path := filepath.Join(c.dir, metadata.ID+".json") - existing, err := os.ReadFile(path) - if err == nil { + manifest, declarative := protocol.BundledManifest(metadata.ID) + existing := []byte(nil) + if record, ok := byID[metadata.ID]; ok { + existing = record.Raw var installed protocol.Manifest if err := json.Unmarshal(existing, &installed); err != nil { - return fmt.Errorf("decode bundled protocol %s: %w", metadata.ID, err) + return fmt.Errorf("decode bundled plugin %s: %w", metadata.ID, err) } - if installed.Metadata.Execution != metadata.Execution { + expectedBackend := "host:" + metadata.ID + if declarative { + expectedBackend = "declarative" + } + if installed.Runtime.Backend != expectedBackend { return fmt.Errorf("protocol id %q is reserved by a bundled plugin", metadata.ID) } if metadata.Enabled { metadata.Enabled = installed.Metadata.Enabled } - } else if !errors.Is(err, os.ErrNotExist) { - return err } - manifest := protocol.Manifest{ - APIVersion: "v1", - Metadata: metadata, - Create: protocol.ManifestOperation{Method: "POST", Path: "/__host__/" + metadata.ID}, - Response: protocol.ManifestResponse{}, + if declarative { + manifest.Metadata.Enabled = metadata.Enabled + manifest.Metadata.Installable = true + } else { + metadata.Execution = "host:" + metadata.ID + manifest = protocol.Manifest{ + APIVersion: "yingce.plugin/v1", + Metadata: metadata, + Runtime: protocol.ManifestRuntime{Backend: "host:" + metadata.ID}, + Permissions: []string{"generation.run"}, + Contributes: protocol.ManifestContributions{Providers: []protocol.ManifestProvider{{ + ID: metadata.ID, Label: metadata.Name, Capabilities: metadata.Categories, Scopes: metadata.Scopes, + Parameters: metadata.Parameters, Create: protocol.ManifestOperation{Method: "POST", Path: "/__host__/" + metadata.ID}, Response: protocol.ManifestResponse{}, + }}}, + } } data, err := json.Marshal(manifest) if err != nil { return fmt.Errorf("encode bundled protocol %s: %w", metadata.ID, err) } - if bytes.Equal(existing, data) { - continue - } - if err := writePluginFile(path, data); err != nil { - return fmt.Errorf("install bundled protocol %s: %w", metadata.ID, err) + record := byID[metadata.ID] + if !bytes.Equal(record.Raw, data) { + now := time.Now().UTC() + if record.InstalledAt.IsZero() { + record.InstalledAt = now + } + record.UpdatedAt = now } + record.ID, record.Raw, record.Source, record.PackagePath = metadata.ID, data, "bundled", "" + byID[metadata.ID] = record } - return nil + result := make([]pluginRegistryRecord, 0, len(byID)) + for _, record := range byID { + if record.Source == "bundled" { + if _, exists := bundledIDs[record.ID]; !exists { + // Bundled records are derived from the current host registry. Dropping + // an obsolete record prevents a removed built-in protocol from surviving + // as a second, stale provider after a host upgrade. + continue + } + } + result = append(result, record) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return c.writeRegistry(result) } func (c *pluginRuntime) reload() error { c.mu.Lock() defer c.mu.Unlock() - entries, err := os.ReadDir(c.dir) + stored, err := c.readRegistry() if err != nil { return err } plugins := make(map[string]pluginRecord) - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { - continue - } - path := filepath.Join(c.dir, entry.Name()) - data, readErr := os.ReadFile(path) - if readErr != nil { - return fmt.Errorf("read protocol plugin %s: %w", entry.Name(), readErr) - } + for _, storedRecord := range stored { + data := storedRecord.Raw if len(data) > protocolPluginMaxBytes { - return fmt.Errorf("protocol plugin %s exceeds %d bytes", entry.Name(), protocolPluginMaxBytes) + return fmt.Errorf("plugin %s exceeds %d bytes", storedRecord.ID, protocolPluginMaxBytes) } var manifest protocol.Manifest if err := json.Unmarshal(data, &manifest); err != nil { - return fmt.Errorf("decode protocol plugin %s: %w", entry.Name(), err) + return fmt.Errorf("decode plugin %s: %w", storedRecord.ID, err) } metadata := manifest.Metadata if strings.TrimSpace(metadata.ID) == "" { - return fmt.Errorf("protocol plugin %s has no metadata id", entry.Name()) + return fmt.Errorf("plugin %s has no metadata id", storedRecord.ID) } if _, exists := plugins[metadata.ID]; exists { return fmt.Errorf("duplicate installed protocol %q", metadata.ID) } - now := time.Now().UTC() - stat, _ := os.Stat(path) - updated := now - if stat != nil { - updated = stat.ModTime().UTC() + packageSHA256 := storedRecord.PackageSHA256 + if packageSHA256 == "" { + packageSHA256 = pluginHash(data) } - plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: pluginSource(metadata), FileName: entry.Name(), SHA256: pluginHash(data), InstalledAt: updated, UpdatedAt: updated, Status: "invalid"} + plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: storedRecord.Source, FileName: storedRecord.FileName, PackagePath: storedRecord.PackagePath, PackageSHA256: packageSHA256, SHA256: packageSHA256, InstalledAt: storedRecord.InstalledAt, UpdatedAt: storedRecord.UpdatedAt, Status: "invalid"} } registry, err := protocol.NewRegistry() if err != nil { return err } for id, record := range plugins { - adapter, loadErr := protocol.LoadInstalledPlugin(record.Raw, func(execution string) (protocol.Adapter, bool) { + adapters, loadErr := protocol.LoadInstalledProviders(record.Raw, func(execution string) (protocol.Adapter, bool) { return protocol.Builtins().Resolve(execution) }) if loadErr != nil { @@ -193,12 +232,22 @@ func (c *pluginRuntime) reload() error { } if !record.Metadata.Enabled { record.Status = "disabled" - _ = registry.Register(protocol.UnavailableAdapter{Info: record.Metadata}) + for _, adapter := range adapters { + info := adapter.Metadata() + info.Enabled = false + _ = registry.Register(protocol.UnavailableAdapter{Info: info}) + } plugins[id] = record continue } - if err := registry.Register(adapter); err != nil { - record.Error = err.Error() + registrationFailed := false + for _, adapter := range adapters { + if err := registry.Register(adapter); err != nil { + record.Error = err.Error() + registrationFailed = true + } + } + if registrationFailed { plugins[id] = record continue } @@ -215,7 +264,7 @@ func (c *pluginRuntime) list() []PluginView { defer c.mu.RUnlock() items := make([]PluginView, 0, len(c.plugins)) for _, item := range c.plugins { - items = append(items, PluginView{Manifest: pluginManifestView(item.Metadata, item.Source), Source: item.Source, FileName: item.FileName, SHA256: item.SHA256, InstalledAt: item.InstalledAt, UpdatedAt: item.UpdatedAt, Status: item.Status, Error: item.Error}) + items = append(items, PluginView{Manifest: pluginManifestView(item.Raw, item.Metadata, item.Source), Source: item.Source, FileName: item.FileName, Package: protocol.PluginPackageFormat, SHA256: item.SHA256, InstalledAt: item.InstalledAt, UpdatedAt: item.UpdatedAt, Status: item.Status, Error: item.Error}) } sort.Slice(items, func(i, j int) bool { return items[i].Manifest.ID < items[j].Manifest.ID }) return items @@ -227,37 +276,75 @@ func (c *pluginRuntime) registrySnapshot() *protocol.Registry { return c.registry } -func (c *pluginRuntime) install(data []byte) (PluginView, error) { +func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error) { c.mutationMu.Lock() defer c.mutationMu.Unlock() - if len(data) == 0 || len(data) > protocolPluginMaxBytes { - return PluginView{}, fmt.Errorf("plugin manifest must be between 1 and %d bytes", protocolPluginMaxBytes) + if len(data) == 0 || len(data) > protocol.PluginPackageMaxBytes { + return PluginView{}, fmt.Errorf("plugin package must be between 1 and %d bytes", protocol.PluginPackageMaxBytes) } - var manifest protocol.Manifest - if err := json.Unmarshal(data, &manifest); err != nil { - return PluginView{}, fmt.Errorf("插件清单 JSON 无效:%w", err) - } - if strings.HasPrefix(strings.TrimSpace(manifest.Metadata.Execution), "host:") { - return PluginView{}, errors.New("上传插件不能使用宿主内置执行器") - } - if _, err := protocol.LoadManifest(data); err != nil { - return PluginView{}, err - } - c.mu.RLock() - _, exists := c.plugins[manifest.Metadata.ID] - c.mu.RUnlock() - if exists { - return PluginView{}, fmt.Errorf("插件 ID %q 已存在;当前上传入口不支持覆盖安装", manifest.Metadata.ID) - } - manifest.Metadata.Enabled = true - data, err := json.Marshal(manifest) + pkg, err := protocol.ParsePluginPackage(data) if err != nil { return PluginView{}, err } - path := filepath.Join(c.dir, manifest.Metadata.ID+".json") - if err := c.persistAndReload(path, data, nil); err != nil { + manifest := pkg.Manifest + if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") { + return PluginView{}, errors.New("上传插件不能使用宿主内置执行器") + } + if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil { + return PluginView{}, err + } + c.mu.RLock() + existing, exists := c.plugins[manifest.Metadata.ID] + c.mu.RUnlock() + if exists && existing.Source == "bundled" { + return PluginView{}, fmt.Errorf("内置插件 %q 不能通过上传覆盖", manifest.Metadata.ID) + } + manifest.Metadata.Enabled = !exists || existing.Metadata.Enabled + manifestData, err := json.Marshal(manifest) + if err != nil { + return PluginView{}, err + } + hash := pluginHash(data) + packageName := filepath.Base(strings.TrimSpace(fileName)) + if packageName == "." || packageName == "" || packageName == string(filepath.Separator) { + packageName = manifest.Metadata.ID + ".yingce-plugin" + } + packagePath := filepath.Join(c.packageDir, hash+".yingce-plugin") + if err := writePluginFile(packagePath, data); err != nil { + return PluginView{}, fmt.Errorf("保存插件包失败:%w", err) + } + stored, err := c.readRegistry() + if err != nil { + _ = os.Remove(packagePath) + return PluginView{}, err + } + previousStored := append([]pluginRegistryRecord(nil), stored...) + now := time.Now().UTC() + newRecord := pluginRegistryRecord{ID: manifest.Metadata.ID, Raw: manifestData, Source: "uploaded", FileName: packageName, PackagePath: filepath.Base(packagePath), PackageSHA256: hash, InstalledAt: now, UpdatedAt: now} + if exists { + newRecord.InstalledAt = existing.InstalledAt + for index := range stored { + if stored[index].ID == manifest.Metadata.ID { + stored[index] = newRecord + break + } + } + } else { + stored = append(stored, newRecord) + } + if err := c.writeRegistry(stored); err != nil { + _ = os.Remove(packagePath) return PluginView{}, fmt.Errorf("保存插件失败:%w", err) } + if err := c.reload(); err != nil { + _ = c.writeRegistry(previousStored) + _ = c.reload() + _ = os.Remove(packagePath) + return PluginView{}, err + } + if exists && existing.PackagePath != "" && existing.PackagePath != filepath.Base(packagePath) { + _ = os.Remove(filepath.Join(c.packageDir, filepath.Base(existing.PackagePath))) + } for _, item := range c.list() { if item.Manifest.ID == manifest.Metadata.ID { return item, nil @@ -284,7 +371,20 @@ func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error) if err != nil { return PluginView{}, err } - if err := c.persistAndReload(filepath.Join(c.dir, record.FileName), data, record.Raw); err != nil { + stored, err := c.readRegistry() + if err != nil { + return PluginView{}, err + } + for index := range stored { + if stored[index].ID == record.Metadata.ID { + stored[index].Raw = data + stored[index].UpdatedAt = time.Now().UTC() + } + } + if err := c.writeRegistry(stored); err != nil { + return PluginView{}, err + } + if err := c.reload(); err != nil { return PluginView{}, err } for _, item := range c.list() { @@ -295,7 +395,9 @@ func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error) return PluginView{}, errors.New("插件状态更新后未加载") } -func pluginManifestView(metadata protocol.Metadata, source string) PluginManifestView { +func pluginManifestView(raw []byte, metadata protocol.Metadata, source string) PluginManifestView { + var manifest protocol.Manifest + _ = json.Unmarshal(raw, &manifest) if source == "bundled" { if adapter, ok := protocol.Builtins().Get(metadata.ID); ok { current := adapter.Metadata() @@ -304,20 +406,14 @@ func pluginManifestView(metadata protocol.Metadata, source string) PluginManifes } protocol.AttachDocumentation(&metadata) } - categories := make([]string, 0, len(metadata.Categories)) - for _, item := range metadata.Categories { - categories = append(categories, string(item)) - } - scopes := make([]string, 0, len(metadata.Scopes)) - for _, item := range metadata.Scopes { - scopes = append(scopes, string(item)) + if manifest.Metadata.ID == "" { + manifest.Metadata = metadata } return PluginManifestView{ - ID: metadata.ID, Name: metadata.Name, Version: metadata.Version, APIVersion: "v1", - Category: "protocol", Description: metadata.Description, Author: metadata.Vendor, - Surfaces: []string{"hybrid"}, Permissions: []string{"generation.run"}, - Trusted: source == "bundled", Kind: "protocol", - Protocol: PluginProtocolView{Categories: categories, Scopes: scopes, Create: metadata.Create, Poll: metadata.Poll, Cancel: metadata.Cancel, ContentType: metadata.ContentType, Documentation: metadata.Documentation, Parameters: metadata.Parameters}, + ID: metadata.ID, Name: metadata.Name, Version: metadata.Version, APIVersion: "yingce.plugin/v1", Entry: manifest.Entry, Surfaces: manifest.Surfaces, + Description: metadata.Description, Documentation: metadata.Documentation, Author: metadata.Vendor, + Permissions: manifest.Permissions, Trusted: source == "bundled", Runtime: manifest.Runtime, + Configuration: manifest.Configuration, Contributes: manifest.Contributes, } } @@ -328,37 +424,57 @@ func (c *pluginRuntime) uninstall(id string) error { record, ok := c.plugins[strings.TrimSpace(id)] c.mu.RUnlock() if !ok { - return fmt.Errorf("协议插件 %q 不存在", id) + return fmt.Errorf("插件 %q 不存在", id) } if record.Source == "bundled" { - return fmt.Errorf("内置协议插件 %q 不能卸载,可停用该插件", id) + return fmt.Errorf("内置插件 %q 不能卸载,可停用该插件", id) } - path := filepath.Join(c.dir, record.FileName) - if err := os.Remove(path); err != nil { + stored, err := c.readRegistry() + if err != nil { + return err + } + filtered := stored[:0] + for _, item := range stored { + if item.ID != record.Metadata.ID { + filtered = append(filtered, item) + } + } + if err := c.writeRegistry(filtered); err != nil { return err } if err := c.reload(); err != nil { - _ = writePluginFile(path, record.Raw) - _ = c.reload() return err } + if record.PackagePath != "" { + _ = os.Remove(filepath.Join(c.packageDir, filepath.Base(record.PackagePath))) + } return nil } -func (c *pluginRuntime) persistAndReload(path string, data, rollback []byte) error { - if err := writePluginFile(path, data); err != nil { +func (c *pluginRuntime) readRegistry() ([]pluginRegistryRecord, error) { + data, err := os.ReadFile(c.registryPath) + if errors.Is(err, os.ErrNotExist) { + return []pluginRegistryRecord{}, nil + } + if err != nil { + return nil, err + } + if len(data) > protocolPluginMaxBytes*64 { + return nil, fmt.Errorf("插件 registry 超过大小限制") + } + var records []pluginRegistryRecord + if err := json.Unmarshal(data, &records); err != nil { + return nil, fmt.Errorf("读取插件 registry 失败:%w", err) + } + return records, nil +} + +func (c *pluginRuntime) writeRegistry(records []pluginRegistryRecord) error { + data, err := json.MarshalIndent(records, "", " ") + if err != nil { return err } - if err := c.reload(); err != nil { - if rollback == nil { - _ = os.Remove(path) - } else { - _ = writePluginFile(path, rollback) - } - _ = c.reload() - return err - } - return nil + return writePluginFile(c.registryPath, data) } func writePluginFile(path string, data []byte) error { @@ -387,7 +503,7 @@ func writePluginFile(path string, data []byte) error { } func pluginSource(metadata protocol.Metadata) string { - if strings.HasPrefix(strings.TrimSpace(metadata.Execution), "host:") { + if _, ok := protocol.Builtins().Get(metadata.ID); ok { return "bundled" } return "uploaded" diff --git a/backend/internal/service/protocol_registry.go b/backend/internal/service/protocol_registry.go index 2325de263..d953d061d 100644 --- a/backend/internal/service/protocol_registry.go +++ b/backend/internal/service/protocol_registry.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "os" + "path/filepath" "strings" "infinite-canvas/backend/internal/model" @@ -40,12 +42,76 @@ func agentProtocolAdapterForContext(ctx context.Context, id string) (protocol.Ag return agentAdapter, ok } -// ProtocolCatalog is the backend source of truth for the frontend plugin -// center's protocol filtering. -// includeUnavailable is reserved for administrators so incomplete plugins remain visible -// with an explicit reason instead of silently becoming a selectable option. -func (s *Service) ProtocolCatalog(scope, capability string, includeUnavailable bool) []protocol.Metadata { - return s.protocolRegistry().List(protocol.Surface(strings.TrimSpace(scope)), protocol.Capability(strings.TrimSpace(capability)), includeUnavailable) +type PluginProviderCatalogItem struct { + ID string `json:"id"` + Version string `json:"version"` + Name string `json:"name"` + Vendor string `json:"vendor"` + Categories []protocol.Capability `json:"categories"` + Scopes []protocol.Surface `json:"scopes"` + Create string `json:"create,omitempty"` + Poll string `json:"poll,omitempty"` + ContentType string `json:"contentType,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + Enabled bool `json:"enabled"` + UnavailableReason string `json:"unavailableReason,omitempty"` + Workflows []protocol.ManifestWorkflow `json:"workflows,omitempty"` +} + +// PluginProviderCatalog projects provider and workflow contributions from the +// unified plugin registry for channel and creation settings. +func (s *Service) PluginProviderCatalog(scope, capability string, includeUnavailable bool) []PluginProviderCatalogItem { + wantScope := protocol.Surface(strings.TrimSpace(scope)) + wantCapability := protocol.Capability(strings.TrimSpace(capability)) + items := make([]PluginProviderCatalogItem, 0) + for _, plugin := range s.Plugins() { + for _, provider := range plugin.Manifest.Contributes.Providers { + if !containsPluginSurface(provider.Scopes, wantScope) || (wantCapability != "" && !containsPluginCapability(provider.Capabilities, wantCapability)) { + continue + } + item := PluginProviderCatalogItem{ID: provider.ID, Version: plugin.Manifest.Version, Name: provider.Label, Vendor: plugin.Manifest.Author, Categories: provider.Capabilities, Scopes: provider.Scopes, BaseURL: provider.BaseURL, Enabled: plugin.Status == "enabled", UnavailableReason: plugin.Error, Workflows: workflowsForProvider(plugin.Manifest.Contributes.Workflows, provider.ID)} + item.Create = operationSummary(provider.Create) + if provider.Poll != nil { + item.Poll = operationSummary(*provider.Poll) + } + item.ContentType = provider.Create.ContentType + if includeUnavailable || item.Enabled { + items = append(items, item) + } + } + } + return items +} + +func containsPluginSurface(items []protocol.Surface, want protocol.Surface) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} +func containsPluginCapability(items []protocol.Capability, want protocol.Capability) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} +func workflowsForProvider(items []protocol.ManifestWorkflow, providerID string) []protocol.ManifestWorkflow { + result := make([]protocol.ManifestWorkflow, 0) + for _, item := range items { + if item.ProviderID == providerID { + result = append(result, item) + } + } + return result +} +func operationSummary(operation protocol.ManifestOperation) string { + path := strings.ReplaceAll(operation.Path, "{{model}}", "{model}") + path = strings.ReplaceAll(path, "{{taskId}}", "{task_id}") + return strings.ToUpper(operation.Method) + " " + path } func (s *Service) protocolRegistry() *protocol.Registry { @@ -113,11 +179,31 @@ func (s *Service) PluginsForUser(actor *model.User) ([]PluginView, error) { return filtered, nil } -func (s *Service) InstallPlugin(data []byte) (PluginView, error) { +func (s *Service) InstallPlugin(data []byte, fileName string) (PluginView, error) { if s.pluginRuntime == nil { return PluginView{}, fmt.Errorf("插件运行时未初始化") } - return s.pluginRuntime.install(data) + return s.pluginRuntime.install(data, fileName) +} + +func (s *Service) PluginPackage(id string) ([]byte, string, error) { + if s.pluginRuntime == nil { + return nil, "", fmt.Errorf("插件运行时未初始化") + } + s.pluginRuntime.mu.RLock() + record, ok := s.pluginRuntime.plugins[strings.TrimSpace(id)] + s.pluginRuntime.mu.RUnlock() + if !ok { + return nil, "", fmt.Errorf("插件 %q 不存在", id) + } + if record.PackagePath == "" { + return nil, "", fmt.Errorf("插件 %q 没有可下载的包文件", id) + } + data, err := os.ReadFile(filepath.Join(s.pluginRuntime.packageDir, filepath.Base(record.PackagePath))) + if err != nil { + return nil, "", fmt.Errorf("读取插件包失败:%w", err) + } + return data, record.FileName, nil } func (s *Service) SetPluginEnabled(id string, enabled bool) (PluginView, error) { diff --git a/backend/internal/service/protocol_runtime_test.go b/backend/internal/service/protocol_runtime_test.go index f55df1bd5..474fce80b 100644 --- a/backend/internal/service/protocol_runtime_test.go +++ b/backend/internal/service/protocol_runtime_test.go @@ -1,9 +1,14 @@ package service import ( + "archive/zip" + "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -23,7 +28,7 @@ func TestPluginViewIncludesDocumentationForEveryBundledProtocol(t *testing.T) { if plugin.Source != "bundled" { continue } - document := strings.TrimSpace(plugin.Manifest.Protocol.Documentation) + document := strings.TrimSpace(plugin.Manifest.Documentation) if document == "" || !strings.Contains(document, "## 影策运行时合同") { t.Errorf("bundled plugin %q has no complete documentation in PluginView", plugin.Manifest.ID) } @@ -37,18 +42,23 @@ func TestPluginRuntimeIsTheProtocolSourceOfTruth(t *testing.T) { t.Fatal(err) } if got := center.registrySnapshot().List("", "", false); len(got) == 0 { - t.Fatal("bundled protocols were not reconciled into a new plugin directory") + t.Fatal("bundled providers were not reconciled into the unified plugin registry") } - manifest := []byte(`{"apiVersion":"v1","metadata":{"id":"uploaded-runtime","version":"1.0.0","name":"Uploaded Runtime","vendor":"Test","categories":["video"],"scopes":["canvas"],"documentation":"# Uploaded Runtime"},"create":{"method":"POST","path":"/tasks","fields":{"prompt":"request.prompt"}},"response":{"statusPaths":["status"]}}`) - plugin, err := center.install(manifest) + if _, err := center.install([]byte(`{"apiVersion":"yingce.plugin/v1"}`), "legacy.json"); err == nil { + t.Fatal("bare JSON manifest was accepted by the upload runtime") + } + manifest := []byte(`{"apiVersion":"yingce.plugin/v1","id":"uploaded-runtime","version":"1.0.0","name":"Uploaded Runtime","author":"Test","documentation":"# Uploaded Runtime","contributes":{"providers":[{"id":"uploaded-runtime","label":"Uploaded Runtime","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/tasks","fields":{"prompt":"request.prompt"}},"response":{"statusPaths":["status"]}}]}}`) + plugin, err := center.install(testPluginPackage(t, manifest), "uploaded-runtime.yingce-plugin") if err != nil { t.Fatal(err) } if plugin.Status != "enabled" || !center.registrySnapshot().IsCapability("uploaded-runtime", protocol.CapabilityVideo) { t.Fatalf("installed plugin was not activated: %#v", plugin) } - if _, err := center.install(manifest); err == nil { - t.Fatal("duplicate plugin id overwrote an installed plugin") + updatedManifest := []byte(`{"apiVersion":"yingce.plugin/v1","id":"uploaded-runtime","version":"2.0.0","name":"Uploaded Runtime v2","author":"Test","documentation":"# Uploaded Runtime v2","contributes":{"providers":[{"id":"uploaded-runtime","label":"Uploaded Runtime v2","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/tasks","fields":{"prompt":"request.prompt"}},"response":{"statusPaths":["status"]}}]}}`) + updated, err := center.install(testPluginPackage(t, updatedManifest), "uploaded-runtime-v2.yingce-plugin") + if err != nil || updated.Manifest.Version != "2.0.0" || updated.Manifest.Name != "Uploaded Runtime v2" { + t.Fatalf("plugin update = %#v, err = %v", updated, err) } if _, err := center.setEnabled("uploaded-runtime", false); err != nil { t.Fatal(err) @@ -67,19 +77,77 @@ func TestPluginRuntimeIsTheProtocolSourceOfTruth(t *testing.T) { } } +func TestPluginRuntimeDropsRemovedBundledProtocol(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", "autodl-comfyui.yingce-plugin")) + if err != nil { + t.Fatal(err) + } + pkg, err := protocol.ParsePluginPackage(data) + if err != nil { + t.Fatal(err) + } + registryData, err := json.Marshal([]pluginRegistryRecord{{ID: "autodl-comfyui", Raw: pkg.ManifestRaw, Source: "bundled"}}) + if err != nil { + t.Fatal(err) + } + dataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(dataDir, "plugin_registry.json"), registryData, 0o600); err != nil { + t.Fatal(err) + } + center, err := newPluginRuntime(dataDir) + if err != nil { + t.Fatal(err) + } + if _, ok := center.registrySnapshot().Resolve("autodl-comfyui"); ok { + t.Fatal("removed bundled AutoDL protocol survived bootstrap") + } +} + +func TestAutoDLPluginPackageInstallsThroughUploadRuntime(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", "autodl-comfyui.yingce-plugin")) + if err != nil { + t.Fatal(err) + } + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + plugin, err := center.install(data, "autodl-comfyui.yingce-plugin") + if err != nil { + t.Fatal(err) + } + if plugin.Status != "enabled" || plugin.Manifest.ID != "autodl-comfyui" || plugin.Package != protocol.PluginPackageFormat { + t.Fatalf("installed AutoDL plugin = %#v", plugin) + } + if !center.registrySnapshot().IsCapability("autodl-comfyui", protocol.CapabilityVideo) { + t.Fatal("AutoDL package provider was not registered") + } + catalog := (&Service{pluginRuntime: center}).PluginProviderCatalog(string(protocol.SurfaceAdminSystemChannel), string(protocol.CapabilityVideo), false) + var autoDL *PluginProviderCatalogItem + for index := range catalog { + if catalog[index].ID == "autodl-comfyui" { + autoDL = &catalog[index] + } + if catalog[index].ID == "autodl-comfyui-plugin" { + t.Fatal("legacy AutoDL provider ID leaked into administrator catalog") + } + } + if autoDL == nil || len(autoDL.Workflows) != 1 { + t.Fatalf("AutoDL administrator catalog = %#v", catalog) + } +} + func TestDeclarativeProtocolRuntimeExecutesCreatePollAndDownload(t *testing.T) { manifest := []byte(`{ - "apiVersion":"v1", - "metadata":{"id":"test-declarative-video-runtime","version":"1.0.0","name":"Test Declarative Video","vendor":"Test","categories":["video"],"scopes":["canvas"],"documentation":"# Test Declarative Video"}, - "create":{"method":"POST","path":"/tasks","fields":{"model":"request.model","prompt":"request.prompt","seconds":"request.duration"}}, - "poll":{"method":"GET","path":"/tasks/{{taskId}}"}, - "response":{"taskIdPaths":["id"],"statusPaths":["status"],"resultUrlPaths":["video_url"],"resultKind":"video"} + "apiVersion":"yingce.plugin/v1", + "id":"test-declarative-video-runtime","version":"1.0.0","name":"Test Declarative Video","author":"Test","documentation":"# Test Declarative Video", + "contributes":{"providers":[{"id":"test-declarative-video-runtime","label":"Test Declarative Video","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/tasks","fields":{"model":"request.model","prompt":"request.prompt","seconds":"request.duration"}},"poll":{"method":"GET","path":"/tasks/{{taskId}}"},"response":{"taskIdPaths":["id"],"statusPaths":["status"],"resultPaths":["video_url"],"resultKind":"video"}}]} }`) center, err := newPluginRuntime(t.TempDir()) if err != nil { t.Fatal(err) } - if _, err := center.install(manifest); err != nil { + if _, err := center.install(testPluginPackage(t, manifest), "test-declarative-video-runtime.yingce-plugin"); err != nil { t.Fatal(err) } @@ -112,3 +180,63 @@ func TestDeclarativeProtocolRuntimeExecutesCreatePollAndDownload(t *testing.T) { t.Fatalf("result = %#v", result) } } + +func TestDeclarativeProtocolRuntimeMapsReferenceImageURL(t *testing.T) { + manifest := []byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"test-declarative-reference-image-runtime","version":"1.0.0","name":"Test Declarative Reference Image","author":"Test","documentation":"# Test Declarative Reference Image", + "contributes":{"providers":[{"id":"test-declarative-reference-image-runtime","label":"Test Declarative Reference Image","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/tasks","fields":{"prompt":"request.prompt","ref_image_0":"request.images.0.url"}},"response":{"statusPaths":["status"],"messagePaths":["msg"]}}]} + }`) + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := center.install(testPluginPackage(t, manifest), "test-declarative-reference-image-runtime.yingce-plugin"); err != nil { + t.Fatal(err) + } + + var createBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/tasks" { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil { + t.Errorf("decode create body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"failed","msg":"stop after request capture"}`)) + })) + defer server.Close() + + config := providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "test-model", APIFormat: "openai", InterfaceType: "test-declarative-reference-image-runtime", AllowLocalChannel: true} + ctx := withProviderOutboundPolicy(context.Background(), config) + ctx = withProtocolRegistry(ctx, center.registrySnapshot()) + _, err = runDeclarativeProtocolTask(ctx, canvasGenerationInput{ + Mode: "video", Prompt: "a clip", Config: config, + ReferenceImages: []providerMedia{{URL: "https://cdn.example/reference.png"}}, + }) + if err == nil || !strings.Contains(err.Error(), "stop after request capture") { + t.Fatalf("runDeclarativeProtocolTask() error = %v", err) + } + if createBody["prompt"] != "a clip" || createBody["ref_image_0"] != "https://cdn.example/reference.png" { + t.Fatalf("create body = %#v", createBody) + } +} + +func testPluginPackage(t *testing.T, manifest []byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + file, err := writer.Create("manifest.json") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(manifest); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 907bca808..959cddd38 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -315,6 +315,9 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } if resumedProviderRequestID(ctx) == "" { requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) || input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo) + if adapter, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok { + requirePublicURL = requirePublicURL || adapter.Metadata().RequiresPublicMediaURLs + } if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil { return nil, err } @@ -4097,7 +4100,7 @@ func ChannelAPIURLForProtocol(baseURL string, path string, interfaceType model.C return apiURLWithDefaultPrefix(baseURL, path, defaultPrefix) } -var channelAPIPrefixes = []string{"/api/plan/v3", "/api/v3", "/v1beta", "/v1", "/v2", "/v3"} +var channelAPIPrefixes = []string{"/api/plan/v3", "/api/v3", "/api/v1", "/v1beta", "/v1", "/v2", "/v3"} func apiURLWithDefaultPrefix(baseURL string, path string, defaultPrefix string) string { base := strings.TrimRight(strings.TrimSpace(baseURL), "/") diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 9e0916d63..e4075c2a1 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -58,6 +58,7 @@ func TestChannelAPIURLNormalizesConfiguredVersionPrefix(t *testing.T) { {name: "path carries v2", base: "http://provider.test:8000/v1", path: "/v2/tasks", want: "http://provider.test:8000/v2/tasks"}, {name: "ark v3", base: "https://ark.example.com/api/v3", path: "/images/generations", want: "https://ark.example.com/api/v3/images/generations"}, {name: "path carries ark v3", base: "https://ark.example.com", path: "/api/v3/images/generations", want: "https://ark.example.com/api/v3/images/generations"}, + {name: "path carries autodl api v1", base: "https://autodl.art", path: "/api/v1/comfyui/comfyui_workflow/workflow-1", want: "https://autodl.art/api/v1/comfyui/comfyui_workflow/workflow-1"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/docs/content/docs/plugins/plugin-system.mdx b/docs/content/docs/plugins/plugin-system.mdx index 25b17d279..7d7a95831 100644 --- a/docs/content/docs/plugins/plugin-system.mdx +++ b/docs/content/docs/plugins/plugin-system.mdx @@ -1,22 +1,17 @@ --- title: 插件系统设计 -description: 影策网页插件系统的目标、扩展边界、插件合同与 Eagle 外部素材来源方案 +description: 影策统一插件系统的扩展边界、Manifest 合同与 Eagle 外部素材来源方案 --- # 插件系统设计 > 状态:已实现首版(Eagle 外部素材来源 + AI 提示词优化器) > -> 当前版本已经包含 Eagle 专属素材管理页面、后端本地桥接、原始文件树浏览、文件读取和写入,以及内置 `prompt-optimizer` AI 能力插件;第三方远程插件安装与完整 SDK 仍按后续阶段建设。 +> 当前版本已经包含 Eagle 专属素材管理页面、后端本地桥接、原始文件树浏览、文件读取和写入,以及内置 `prompt-optimizer` AI 能力插件。第三方包统一使用 `.yingce-plugin`;远程 Web 入口仍必须经过沙箱运行时和宿主 RPC。 ## 1. 背景与范围 -当前项目已经包含一个面向 Codex App 的 `plugins/infinite-canvas` 插件。该插件负责注册 MCP、连接本地 Canvas Agent,并让 Codex 读取或操作画布。 - -本文讨论的是另一套能力:让网页应用能够安装、启用和管理由其他开发者提供的扩展。两者保持边界清晰: - -- Codex 插件:面向 Codex / MCP / Canvas Agent。 -- 网页插件:面向影策画布、素材、工作流和外部服务集成。 +当前项目包含 Codex App 的 MCP 包和影策网页插件贡献。它们可以使用不同的运行时,但安装、权限、版本和能力发现都遵循同一个影策插件 Manifest;MCP 连接本身仍受独立 Agent 安全边界约束。 网页插件的第一目标不是开放所有页面代码,而是提供稳定、可审计、可逐步扩展的宿主 API,让第三方开发者能够在不修改主应用源码的情况下接入能力。 @@ -80,36 +75,40 @@ Eagle 插件属于此类,具体方案见第 8 节。 ## 5. 插件包与清单 -插件以一个远程或本地可解析的清单作为入口。清单负责描述插件,不负责承载业务逻辑。 +插件以一个统一的 `.yingce-plugin` 包作为入口。包内固定包含 `manifest.json`,也可以包含受沙箱约束的 Web 入口和静态资源。Manifest 负责描述插件能力、权限和运行时;包文件负责承载对应版本的实现,不能再把“协议 JSON”和“UI 插件源码”拆成两套安装系统。 -建议的最小清单: +建议的最小包结构: + +```text +eagle-asset-connector.yingce-plugin +├── manifest.json +├── web/entry.js # 有 UI/功能代码时声明 +└── web/assets/... # 可选静态资源 +``` + +`manifest.json` 建议从以下最小清单开始: ```json { + "apiVersion": "yingce.plugin/v1", "id": "eagle-asset-connector", "name": "Eagle 素材库", - "version": "0.1.0", - "apiVersion": "1", - "entry": "https://example.com/plugins/eagle-asset-connector.js", - "category": "asset-connector", - "permissions": [ - "canvas.read", - "canvas.write", - "asset.read", - "asset.import", - "asset.upload" - ], - "dependencies": {}, - "integrity": "sha256-..." + "version": "0.3.0", + "author": "影策社区", + "permissions": ["asset.read", "asset.search", "asset.upload"], + "configuration": { "fields": [{ "name": "baseUrl", "type": "url", "required": true }] }, + "contributes": { "assetSources": ["eagle"] } } ``` +带 Web 功能的包增加 `entry: "web/entry.js"`、`runtime.web: "sandbox"` 或 `"worker"`。上传入口只接受 `.yingce-plugin`,服务端会校验 ZIP 路径、大小、重复文件、Manifest 和入口文件,不执行包内 JavaScript;当前版本先完成包校验、存储和入口合同,未授权的 Web 代码不会自动进入主页面。Provider 仍由后端声明式运行时执行;UI/功能代码通过隔离运行时和权限化宿主 API 接入。 + 字段约束: - `id`:全局唯一,使用小写 kebab-case;安装后不能随意变更。 - `version`:遵循 SemVer,用于更新和回滚判断。 -- `apiVersion`:表示插件依赖的宿主 API 版本,不等同于插件版本。 -- `entry`:插件入口地址;正式环境应支持 HTTPS。 +- `apiVersion`:固定为 `yingce.plugin/v1`,不接受旧版本或平行 Manifest。 +- `contributes`:声明 provider、workflow、canvasNodes、transforms、assetSources、agents 等能力。 - `permissions`:最小权限集合,宿主在安装和首次使用时展示。 - `dependencies`:插件依赖的其他插件或宿主能力。 - `integrity`:用于校验远程产物是否被替换。 @@ -121,12 +120,13 @@ Eagle 插件属于此类,具体方案见第 8 节。 建议第一版 SDK 包含以下能力: ```ts -export interface CanvasPlugin { +export interface CanvasPluginManifest { + apiVersion: "yingce.plugin/v1"; id: string; + name: string; version: string; - apiVersion: string; - setup(api: PluginSetupApi): void | (() => void); - nodes?: CanvasNodeDefinition[]; + permissions: string[]; + contributes: Record; } export interface PluginSetupApi { diff --git a/plugin-packages/autodl-comfyui.yingce-plugin b/plugin-packages/autodl-comfyui.yingce-plugin new file mode 100644 index 0000000000000000000000000000000000000000..bbcf657bce65ba013919dde260eca9adff58a0d8 GIT binary patch literal 2076 zcmV+%2;=uqO9KQH000080Ip&hT}X|%TA&920Ol0{01W^D0BvDzX=Y_}bS`RhZ*J8X zT~pgu_C3EsLtb`gT07riGCMoNrff@_q-hEJk}_D>7o$d&R7s|xlo^`@2n1t72qi!Q zBzQ|hY{P`4j*a2RR9CW}@)vsUy|OIZlGEm?%|k%v`}>^B*a?p7;1%J2NYw;c>bS&p zj0#dTi~@=nj|x(7jtz~*1%{FM7$UeXzJzVAI zsp_)_jAW!P{dC||;1q*`uwhvxKwRFpvdh-?5uPhDTm)%hRZw)BtD0D?98YshMW9R7 z9X0sZj8R@UUnjY)>ph$~pTUzEd^lldGt2;MX~Vp?@LOWSmMe$jF(m1HP4Y?3)9!Di zZ={nasl4=a@`3NI;Zo5kmGJD=yTjGc^)GvGa6yP1L?~#-qxN&)ds)3TB+B0h$7;U~ z2$As+SYZ|>aq5vdJ8Kp;tI3B(A#1JPHFvh~)B8C4lbL%B&KQLv7jiL%-W|^2wM{d- zZRXQP;YF+O)jMUJ&Kso+i<1Md9Bf&0IXt@wK>;B~Ayr*ZRQ{hdj&tVHTbw&K3MHfT z+}ccnX~au&BU8ygt9$ur_aAUlMOs|cgJU|c-KzO%$Ir2Zd|TT^NOxlKO@PpYMrN^aZy=}q;~ zF`+-tSwAoSmPq1zQ@ZyC|#oAq_abL=tb4f0AuJe=)O1jWV3O9FNIB@p+ zgi)9^4ma__JPR*g->K(q?EtEynwT-~|5C|k98cdJu9?ew_}Nnjg3~V`?B>ibc>j^P z{*skIJw}!MujW2zny=0rm{0E1i>-zza-a8pd5si7`av}TPAIAzQ*>%;B(Bm@rXP?9 zS&Y+9LjCrQIX8o|D_}Jg8BR}B5_j<0ku@`26QrV{0Z2s@4V(&bmFZV*NGI{%i6<*g zYiZw1FB+vcAd<l;?~2rs1Y z9;#H(_|I&$qe}_oAXhNVl@Z2O`*k!8f`LSl-n299h$0|KEpRN;V z=&Qc&zV1 A2E`LSOe?yVi5<(-3E_Y~sDU#5o!g)<&ei=NGFh0GY?zcddmfupcZB z_4Zx9+}+!23xTduM5LpLOAHJyJi9??|J&7bh3IooczeHcK#Z&|fxCJ9Folatpo$2J zAX{cki$X^ot{TTrtVfT5i&?~2NK;Vx%q^od&xInq&IedKYd8A?aaDw}$d_<>8JO)B zT;Ib_67@W>Z*wKj63RZoWP8wUPFHe!M04Q)x|OM_r|oc+6jE*u3<0cBxL+xkp=`lk zQnvT@*j}i-zH1g22xMZ%P7BVhn9FZrG?-g34pwhA&2U%+{r?!&bw#@r46>aS;8i^c z(_eGu7a42+bQXhx)R>@=Ev3O)xCtNtMv;VAe+AUILJLlJ?5iH~#upr5jbWobx= z+RX?8#u(|4fW$~$AZNoVuC_P9jxW%tEu3QXQH5eO6jqV$#)$kN5?#xdc8QxnMc>5* z6_I7Ft8wJQG;LJ8wF_;^x#d7B7^O)k`0mDI{YHXi3mm6E`{Dobn*X{g490b&)xC!~ zH6lb{_j?Dx_oVP~*5k6UDZ{+N4+^573&^dD;)n%uhXd(%v1wshX=UIeu%H6ksHUUX z$KhdKlE8|O+!S#Qsey1@(`6rmFfWbpS`!Qkh6dl$ltErYeJb^~wQfBUtX+=}ibD7* zDMjGZTJue=D<>5;tX5cqbOtusM@!YS9sxNF#g0@v7xigc_hsCrS>aVEZM`w?EGL=M)>`6@@VQ3U6u#_o-;67vNB-J`qdet--08{*F;#N~s(h~^novzA?F@O!HWkOmn_3x$9lRlK*Z~30 zp%rP`g5PRQUe{MZQ^4)Qyu@l5X?nn?RO?H{q4cW~SL9vPb?A@aRBQRUu0S^%1!?eH zgEPRN<+KOO>GmvVJXp@OXF2P^a<)CoIS-a|?OD!yu$*tta>0Y;LVK2r9xNBzvvhi} zbW#@2?r0dsvMBoZ2E6Y+ur+FrKKknGi9m~Zss}O!PO6snWL(!lTl112B~Q*!`!(tI zu)o1n4Ic4vt(^!);V%KHs;v45AZN5cXAEf?WTBmMo1|qd<0XQI zc-qiS^U<~S&o{TPYsJ^x^J`!7K07gf;&)I>0RkQa6aWAK2mr2P8eK?@xLTkG008C{ z000dD0000000031005+c00000ZDDR{W@U49E^2dcZcs}F1^@s60096206PEx0Lupe G00023gzJR> literal 0 HcmV?d00001 diff --git a/plugin-packages/autodl-comfyui/manifest.json b/plugin-packages/autodl-comfyui/manifest.json new file mode 100644 index 000000000..b3a7dc36a --- /dev/null +++ b/plugin-packages/autodl-comfyui/manifest.json @@ -0,0 +1,150 @@ +{ + "apiVersion": "yingce.plugin/v1", + "id": "autodl-comfyui", + "name": "AutoDL ComfyUI 视频", + "version": "1.0.0", + "author": "影策社区", + "description": "通过 AutoDL.Art ComfyUI 工作流 API 接入异步视频生成。", + "documentation": "# AutoDL ComfyUI 视频\n\n## 接口\n\n创建任务:`POST /api/v1/comfyui/comfyui_workflow/{workflow_id}`。清单把渠道中的模型字段作为 `workflow_id`,因此每个 ComfyUI 工作流都可以通过工作流贡献加入,不需要修改宿主代码。\n\n查询任务:`GET /api/v1/comfyui/comfyui_workflow/result/{task_id}`。\n\n## 工作流\n\n示例 `minimax_h3_lightx2v_no_pic` 作为 MiniMax H3 文生视频入口,默认时长为 5 秒、分辨率为 `768p竖`。支持 `480p竖`、`768p竖`、`480p横`、`768p横`。上传后可以在渠道中直接选择该工作流;新增工作流只需扩展本清单。\n\n## 请求与鉴权\n\n创建请求为 JSON,发送 `prompt`、`duration`、`resolution`,并按存在的参考图片发送 `ref_image_0` 至 `ref_image_8`。分辨率字段由插件在发送前统一转换为小写。参考图片由宿主转换为短期签名公网 URL。AutoDL Token 在渠道密钥字段中配置,宿主以后端出站请求发送 `Authorization: Bearer `;清单不包含密钥。\n\n## 状态与结果\n\n`QUEUED` 和 `RUNNING` 映射为统一任务的排队与处理中,`SUCCESS` 和 `completed` 映射为成功,`FAILED` 映射为失败。错误响应的 `code` 和 `msg` 会保留为任务错误。成功结果从 `data.results[].url` 读取为视频地址。AutoDL 地址是短期地址,清单标记为临时结果,宿主会在完成时下载并保存到影策资源存储。\n\n官方文档:[AutoDL ComfyUI API](https://autodl.art/docs/comfyui_api/)。", + "permissions": [ + "generation.run", + "media.read" + ], + "configuration": { + "fields": [ + { + "name": "apiKey", + "type": "secret", + "label": "AutoDL Token", + "required": true, + "description": "填写 AutoDL Token;仅保存到渠道密钥配置。" + } + ] + }, + "contributes": { + "providers": [ + { + "id": "autodl-comfyui", + "label": "AutoDL ComfyUI 视频", + "capabilities": [ + "video" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation" + ], + "baseUrl": "https://autodl.art", + "requiresPublicMediaUrls": true, + "auth": { + "type": "bearer", + "field": "apiKey", + "header": "Authorization" + }, + "parameters": [ + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt", + "description": "视频生成提示词。" + }, + { + "name": "duration", + "type": "integer", + "mapping": "duration", + "description": "视频时长,单位由工作流决定。" + }, + { + "name": "resolution", + "type": "string", + "mapping": "resolution", + "description": "工作流支持的分辨率档位。" + } + ], + "create": { + "method": "POST", + "path": "/api/v1/comfyui/comfyui_workflow/{{model}}", + "contentType": "application/json", + "fields": { + "prompt": "request.prompt", + "duration": "request.duration", + "resolution": "request.resolution|lower", + "ref_image_0": "request.images.0.url", + "ref_image_1": "request.images.1.url", + "ref_image_2": "request.images.2.url", + "ref_image_3": "request.images.3.url", + "ref_image_4": "request.images.4.url", + "ref_image_5": "request.images.5.url", + "ref_image_6": "request.images.6.url", + "ref_image_7": "request.images.7.url", + "ref_image_8": "request.images.8.url" + } + }, + "poll": { + "method": "GET", + "path": "/api/v1/comfyui/comfyui_workflow/result/{{taskId}}" + }, + "response": { + "taskIdPaths": [ + "data.task_id" + ], + "statusPaths": [ + "data.status" + ], + "errorPaths": [ + "code" + ], + "messagePaths": [ + "data.message", + "msg" + ], + "resultPaths": [ + "data.results" + ], + "resultKind": "video", + "resultEphemeral": true + } + } + ], + "workflows": [ + { + "id": "minimax_h3_lightx2v_no_pic", + "label": "MiniMax H3 文生视频", + "providerId": "autodl-comfyui", + "capability": "video", + "parameters": [ + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt" + }, + { + "name": "duration", + "type": "integer", + "mapping": "duration", + "description": "视频时长。" + }, + { + "name": "resolution", + "type": "string", + "mapping": "resolution", + "values": [ + "480p竖", + "768p竖", + "480p横", + "768p横" + ], + "description": "输出分辨率档位。" + } + ], + "defaults": { + "duration": 5, + "resolution": "768p竖" + } + } + ] + } +} diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index fa06ce8dd..5c7d2bbcd 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -10,7 +10,7 @@ import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset"; import { isCanvasFolderNode } from "@/lib/canvas/canvas-frame"; import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry"; import { useThemeStore } from "@/stores/use-theme-store"; -import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas"; +import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas"; type CanvasAssetCategory = NonNullable["assetCategory"]>; @@ -33,7 +33,7 @@ type CanvasNodeContextMenuProps = { canRedo: boolean; canPaste: boolean; onClose: () => void; - onAddNode: (type: CanvasNodeType) => void; + onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: (position: Position) => void; @@ -258,7 +258,7 @@ export function CanvasNodeContextMenu({ ); } -function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) { +function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const left = getSubmenuLeft(parentPosition.left); const createContext: AddNodeMenuContext = { diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 0771bb12e..2f51f55f2 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -15,6 +15,7 @@ import { resourceIdFromStorageKey } from "@/services/api/resources"; import type { GenerationTask } from "@/services/api/task-center"; import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { createDefaultSubtitleStyle } from "@/types/timeline"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { useCanvasNodeActions } from "./canvas-node-action-context"; @@ -61,11 +62,34 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) { if (props.node.metadata?.status === "loading") return ; if (props.node.metadata?.status === "error") return ; + const pluginDefinition = getNodeDefinition(props.node.type)?.plugin; + if (pluginDefinition) return ; const Renderer = nodeContentRenderers[props.node.type]; return Renderer ? : ; } -const nodeContentRenderers = { +function PluginCanvasNodeContent({ node, theme, renderer, schema }: CanvasNodeContentProps & { renderer: "declarative" | "sandbox"; schema: Record }) { + if (renderer === "sandbox") { + return
+ 插件节点等待隔离运行时 +
; + } + const data = node.metadata?.pluginData || {}; + const fields = Object.keys(schema.properties && typeof schema.properties === "object" ? schema.properties as Record : schema); + return
+ {fields.length ? fields.map((field) =>
+ {field} + {formatPluginValue(data[field] ?? (field === "content" ? node.metadata?.content : undefined))} +
) : {node.metadata?.content || "插件节点"}} +
; +} + +function formatPluginValue(value: unknown) { + if (value === undefined || value === null || value === "") return "未设置"; + return typeof value === "string" ? value : JSON.stringify(value); +} + +const nodeContentRenderers: Partial ReactNode>> = { [CanvasNodeType.Text]: TextContent, [CanvasNodeType.Script]: UnknownNodeContent, [CanvasNodeType.Skill]: SkillContent, @@ -82,7 +106,7 @@ const nodeContentRenderers = { [CanvasNodeType.Compare]: CompareNodeContent, [CanvasNodeType.Chart]: ChartNodeContent, [CanvasNodeType.ColorGrade]: ColorGradeNodeContent, -} satisfies Record ReactNode>; +}; function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) { const shapeCount = node.metadata?.drawingShapeCount || 0; diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 04ea52ee0..1f58961a1 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -8,7 +8,7 @@ import { canvasThemes } from "@/lib/canvas-theme"; import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { useThemeStore } from "@/stores/use-theme-store"; -import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; +import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Position } from "@/types/canvas"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry"; import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content"; @@ -660,7 +660,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the ); } -function nodeTypeIcon(type: CanvasNodeType) { +function nodeTypeIcon(type: CanvasNodeTypeId) { if (type === CanvasNodeType.Image) return ImageIcon; if (type === CanvasNodeType.Video) return Video; if (type === CanvasNodeType.Audio) return Music2; diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx index 91824df54..638e13938 100644 --- a/web/src/components/canvas/canvas-toolbar.tsx +++ b/web/src/components/canvas/canvas-toolbar.tsx @@ -13,7 +13,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme"; import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry"; import { useThemeStore } from "@/stores/use-theme-store"; -import type { CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas"; +import type { CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas"; export function CanvasToolbar({ selectedCount, @@ -65,7 +65,7 @@ export function CanvasToolbar({ onAddFrame: () => void; onAddFolder: () => void; onAddDrawing: () => void; - onAddExtensionNode: (type: CanvasNodeType) => void; + onAddExtensionNode: (type: CanvasNodeTypeId) => void; onOpenDirector: () => void; onUndo: () => void; onRedo: () => void; diff --git a/web/src/lib/canvas/canvas-node-asset.ts b/web/src/lib/canvas/canvas-node-asset.ts index d9bb34d44..2a5f48d1c 100644 --- a/web/src/lib/canvas/canvas-node-asset.ts +++ b/web/src/lib/canvas/canvas-node-asset.ts @@ -1,6 +1,6 @@ import { getDataUrlByteSize } from "@/lib/image-utils"; import type { Asset, AssetCategory, NewAsset } from "@/stores/use-asset-store"; -import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas"; export type CanvasAssetSource = "canvas-generation" | "canvas-upload" | "canvas-manual"; @@ -124,7 +124,7 @@ export function canvasNodeAssetCategory(node: CanvasNodeData): AssetCategory { return "other"; } -function canvasAssetFallbackTitle(type: CanvasNodeType) { +function canvasAssetFallbackTitle(type: CanvasNodeTypeId) { if (type === CanvasNodeType.Image) return "画布图片"; if (type === CanvasNodeType.Video) return "画布视频"; if (type === CanvasNodeType.Audio) return "画布音频"; diff --git a/web/src/lib/canvas/canvas-node-size.ts b/web/src/lib/canvas/canvas-node-size.ts index a131e76b7..d746cb95c 100644 --- a/web/src/lib/canvas/canvas-node-size.ts +++ b/web/src/lib/canvas/canvas-node-size.ts @@ -1,5 +1,5 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; -import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { CanvasNodeType, isBuiltinCanvasNodeType, type CanvasNodeData } from "@/types/canvas"; export const MEDIA_NODE_MIN_SIZE = { width: 420, height: 236 } as const; export const VIDEO_NODE_MAX_SIZE = { width: 720, height: 520 } as const; @@ -34,10 +34,11 @@ export function ensureMediaNodeMinimumSize(node: CanvasNodeData) { const title = node.title === "New Generation" ? "图片" : node.title === "Video" ? "视频" : node.title; let width = node.width; let height = node.height; - const emptyStage = NODE_DEFAULT_SIZE[node.type]; + const emptyStage = isBuiltinCanvasNodeType(node.type) ? NODE_DEFAULT_SIZE[node.type] : undefined; const shouldPromoteEmptyStage = !node.metadata?.content && !node.metadata?.freeResize && !node.metadata?.locked + && emptyStage !== undefined && width * height < emptyStage.width * emptyStage.height; if (shouldPromoteEmptyStage) { width = emptyStage.width; diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index de03696f9..a7ffcc404 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -7,9 +7,10 @@ import type { NodeGenerationInput } from "@/components/canvas/canvas-node-genera import { isFrameNode } from "@/lib/canvas/canvas-frame"; import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { scopedLocalStorage } from "@/lib/user-scope"; import type { GenerationTask } from "@/services/api/task-center"; -import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas"; const CANVAS_WORKSPACE_MODE_STORAGE_KEY = "canvas-workspace-mode-v1"; @@ -32,8 +33,11 @@ export function persistCanvasWorkspaceMode(mode: CanvasWorkspaceMode) { } -export function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData { - const spec = getNodeSpec(type); +export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData { + const builtinSpec = type in NODE_DEFAULT_SIZE ? getNodeSpec(type as CanvasNodeType) : undefined; + const pluginDefinition = getNodeDefinition(type); + const spec = builtinSpec || (pluginDefinition ? { width: pluginDefinition.defaultSize.width, height: pluginDefinition.defaultSize.height, title: pluginDefinition.defaultTitle, metadata: pluginDefinition.defaultMetadata } : undefined); + if (!spec) throw new Error(`未注册的画布节点类型:${type}`); const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; return { diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index b08592c01..feef4425b 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -3,7 +3,7 @@ import { getNodeResourceKind } from "@/lib/canvas/node-registry"; import { seedanceReferenceLabel } from "@/lib/seedance-video"; import type { Skill } from "@/services/api/skills"; import type { Asset, AssetCategory } from "@/stores/use-asset-store"; -import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas"; export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character"; @@ -17,7 +17,7 @@ export type CanvasResourceReference = { storageKey?: string; text?: string; active: boolean; - sourceType?: CanvasNodeType; + sourceType?: CanvasNodeTypeId; skill?: Skill; assetId?: string; category?: AssetCategory; diff --git a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx index 00e6cf0d6..8cadef136 100644 --- a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx +++ b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx @@ -16,8 +16,7 @@ const DEFAULT_MIN_SIZE = { width: 220, height: 160 } as const; * 尺寸、默认标题与初始 metadata 不在这里重复——它们的数据源是 constant/canvas.ts 的 * NODE_SPECS,下方装配时统一读取。 * - * `satisfies Record` 是刻意的:往 CanvasNodeType 加成员后若不补定义, - * 这里会编译失败,而不是在运行期悄悄回落成兜底值。 + * 内置节点保持集中定义;第三方节点通过同一 registry 以命名空间 ID 动态注册。 */ const BUILTIN_NODE_TRAITS = { [CanvasNodeType.Image]: { @@ -166,7 +165,7 @@ const BUILTIN_NODE_TRAITS = { resourceKind: () => "image", inputKind: "image", }, -} satisfies Record>; +} satisfies Record>; export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => { const spec = NODE_SPECS[type]; diff --git a/web/src/lib/canvas/node-registry/index.ts b/web/src/lib/canvas/node-registry/index.ts index 8c8d0dc79..173f980e7 100644 --- a/web/src/lib/canvas/node-registry/index.ts +++ b/web/src/lib/canvas/node-registry/index.ts @@ -1,3 +1,3 @@ export type { CanvasNodeDefinition, CanvasNodeInputKind } from "./node-definition"; -export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry"; +export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, registerPluginCanvasNodes, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry"; import "./definitions"; diff --git a/web/src/lib/canvas/node-registry/node-definition.ts b/web/src/lib/canvas/node-registry/node-definition.ts index 5301d6d37..98b9733cf 100644 --- a/web/src/lib/canvas/node-registry/node-definition.ts +++ b/web/src/lib/canvas/node-registry/node-definition.ts @@ -1,7 +1,8 @@ import type { ReactNode } from "react"; import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references"; -import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas"; +import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId } from "@/types/canvas"; +import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types"; /** 作为上游输入被容量校验计数时归入的类别 */ export type CanvasNodeInputKind = "image" | "video" | "audio" | "text"; @@ -13,7 +14,7 @@ export type CanvasNodeInputKind = "image" | "video" | "audio" | "text"; * 避免同一份知识散落到创建菜单、搜索弹窗、拉伸逻辑等多处后各自漂移。 */ export type CanvasNodeDefinition = { - type: CanvasNodeType; + type: CanvasNodeTypeId; /** UI 短标签——创建菜单等处显示,如「文本」 */ label: string; /** 列表/搜索标签,缺省派生为 `${label}节点` */ @@ -45,4 +46,23 @@ export type CanvasNodeDefinition = { * 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。 */ inputKind?: CanvasNodeInputKind; + plugin?: { + pluginId: string; + renderer: PluginCanvasNodeContribution["renderer"]; + schema: Record; + }; }; + +export function canvasNodeDefinitionFromPlugin(pluginId: string, contribution: PluginCanvasNodeContribution): CanvasNodeDefinition { + return { + type: contribution.id, + label: contribution.label, + icon: null, + defaultTitle: contribution.defaultTitle, + defaultSize: contribution.defaultSize, + defaultMetadata: { pluginId, pluginNodeId: contribution.id, pluginData: {}, content: "" }, + minSize: { width: Math.min(contribution.defaultSize.width, 220), height: Math.min(contribution.defaultSize.height, 160) }, + showInCreateMenu: true, + plugin: { pluginId, renderer: contribution.renderer, schema: contribution.schema }, + }; +} diff --git a/web/src/lib/canvas/node-registry/node-registry.ts b/web/src/lib/canvas/node-registry/node-registry.ts index c43515506..dfe7e316d 100644 --- a/web/src/lib/canvas/node-registry/node-registry.ts +++ b/web/src/lib/canvas/node-registry/node-registry.ts @@ -1,11 +1,12 @@ -import type { CanvasNodeData, CanvasNodeType } from "@/types/canvas"; +import type { CanvasNodeData, CanvasNodeTypeId } from "@/types/canvas"; +import type { PluginCanvasNodeContribution } from "@/lib/plugins/plugin-types"; -import type { CanvasNodeDefinition } from "./node-definition"; +import { canvasNodeDefinitionFromPlugin, type CanvasNodeDefinition } from "./node-definition"; /** 模块级注册表 */ -const definitions = new Map(); +const definitions = new Map(); /** 节点类型 → 归属方。内置节点归属 "builtin",为后续扩展留出隔离位。 */ -const ownerByType = new Map(); +const ownerByType = new Map(); /** 未注册类型的兜底最小尺寸——与非媒体节点的历史下限一致 */ const FALLBACK_MIN_SIZE = { width: 220, height: 160 } as const; @@ -18,6 +19,11 @@ export function registerNodeDefinitions(defs: CanvasNodeDefinition[], ownerId = } } +/** Registers schema-driven canvas nodes from the unified plugin manifest. */ +export function registerPluginCanvasNodes(pluginId: string, nodes: PluginCanvasNodeContribution[]) { + registerNodeDefinitions(nodes.map((node) => canvasNodeDefinitionFromPlugin(pluginId, node)), pluginId); +} + /** * 注销某归属方的全部节点定义。 * 只删归属该 ownerId 的条目——内置节点不会被其他归属方的增删波及。 @@ -30,11 +36,11 @@ export function unregisterNodeDefinitions(ownerId: string) { } } -export function getNodeDefinition(type: CanvasNodeType) { +export function getNodeDefinition(type: CanvasNodeTypeId) { return definitions.get(type); } -export function getNodeOwnerId(type: CanvasNodeType) { +export function getNodeOwnerId(type: CanvasNodeTypeId) { return ownerByType.get(type) || "builtin"; } @@ -48,23 +54,23 @@ export function listCreatableNodeDefinitions() { } /** UI 短标签 */ -export function getNodeLabel(type: CanvasNodeType) { +export function getNodeLabel(type: CanvasNodeTypeId) { return definitions.get(type)?.label || "未知节点"; } /** 列表/搜索标签,缺省派生自 label */ -export function getNodeListLabel(type: CanvasNodeType) { +export function getNodeListLabel(type: CanvasNodeTypeId) { const def = definitions.get(type); if (!def) return "未知节点"; return def.listLabel || `${def.label}节点`; } -export function getNodeIcon(type: CanvasNodeType) { +export function getNodeIcon(type: CanvasNodeTypeId) { return definitions.get(type)?.icon ?? null; } /** 手动拉伸的最小尺寸 */ -export function getNodeMinSize(type: CanvasNodeType) { +export function getNodeMinSize(type: CanvasNodeTypeId) { return definitions.get(type)?.minSize ?? FALLBACK_MIN_SIZE; } @@ -87,6 +93,6 @@ export function getNodeGenerationMode(node: CanvasNodeData) { } /** 该节点作为上游输入被计数时的类别,不参与计数返回 undefined */ -export function getNodeInputKind(type: CanvasNodeType) { +export function getNodeInputKind(type: CanvasNodeTypeId) { return definitions.get(type)?.inputKind; } diff --git a/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx index bfee53b59..a584c243d 100644 --- a/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx +++ b/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx @@ -2,7 +2,7 @@ import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2, import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry"; import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry"; -import { CanvasNodeType } from "@/types/canvas"; +import { CanvasNodeType, type CanvasNodeTypeId } from "@/types/canvas"; /** 真正创建节点的命令,文案与图标统一取自节点注册表。 */ function nodeCommand(type: CanvasNodeType, rest: Omit): AddNodeMenuCommand { @@ -13,7 +13,7 @@ function nodeCommand(type: CanvasNodeType, rest: Omit ctx.handlers.onAddExtensionNode(type) }; } diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts index a83be03bb..5f2501af6 100644 --- a/web/src/lib/canvas/tool-registry/tool-definition.ts +++ b/web/src/lib/canvas/tool-registry/tool-definition.ts @@ -3,7 +3,7 @@ import type { MouseEvent } from "react"; import type { CanvasAlignmentMode } from "@/lib/canvas/canvas-layout"; import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; -import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeType, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas"; +import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas"; /** 工具栏标识——每个工具栏有独立的注册表与偏好 */ export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu"; @@ -43,7 +43,7 @@ export type ToolbarHandlers = { * 不给每种扩展节点单开一个 onAddXxx —— 那会让 ToolbarHandlers 随节点数线性膨胀, * 而它们的创建逻辑完全一致(都只是 createNode(type))。 */ - onAddExtensionNode: (type: CanvasNodeType) => void; + onAddExtensionNode: (type: CanvasNodeTypeId) => void; onChooseStyle: () => void; onOpenDirector: () => void; // 主工具栏——资源 @@ -102,7 +102,7 @@ export type ToolbarHandlers = { /** 工具运行时可见的上下文。工具定义通过纯函数读取状态 */ export type ToolContext = { selectedCount: number; - selectedNodeTypes: Set; + selectedNodeTypes: Set; selectedVideoCount: number; canvasTool: CanvasToolMode; workspaceMode: CanvasWorkspaceMode; diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts index 5f303a860..c1ac8e28c 100644 --- a/web/src/lib/model-protocols.ts +++ b/web/src/lib/model-protocols.ts @@ -1,6 +1,7 @@ export type ModelProtocol = string; export type ProtocolCapability = "text" | "image" | "video" | "audio"; -export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean }; +export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: Array<{ name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string }>; defaults?: Record }; +export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; workflows?: ModelProtocolWorkflow[] }; export function protocolGroups(protocols: ModelProtocolDefinition[]) { return (["text", "image", "video", "audio"] as ProtocolCapability[]).map((capability) => ({ label: { text: "文本", image: "图片", video: "视频", audio: "音频" }[capability], options: protocols.filter((item) => item.capability === capability && item.enabled !== false).map((item) => ({ label: `${item.label} · ${item.create.replace(/^POST /, "")}`, value: item.value })) })); diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index 8cce7f8ab..2a1193229 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -250,7 +250,11 @@ export function resolveModelGenerationDefaults( const channel = resolveModelChannel(config, model); const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model)); const isManagedModel = Boolean(cost?.logicalModelId || cost?.logicalCapabilitySpec); - const source = (key: keyof ModelGenerationDefaults) => explicit[key] ?? (isManagedModel ? undefined : fallback[key]); + // A channel model capability profile is an explicit per-model contract too. + // The persisted global values are legacy defaults and must not override a + // model's configured duration, ratio, or resolution on a new canvas node. + const hasModelCapabilityProfile = Boolean(cost?.capabilityConfig); + const source = (key: keyof ModelGenerationDefaults) => explicit[key] ?? (isManagedModel || hasModelCapabilityProfile ? undefined : fallback[key]); const profile = modelCapabilityConfigFor(config, model); if (capability === "image" && profile.image) { diff --git a/web/src/lib/plugins/builtin/eagle.ts b/web/src/lib/plugins/builtin/eagle.ts index 6e2bce9f7..5b887311f 100644 --- a/web/src/lib/plugins/builtin/eagle.ts +++ b/web/src/lib/plugins/builtin/eagle.ts @@ -96,15 +96,21 @@ export const eagleAssetPlugin: RegisteredPlugin = { version: "0.3.0", publishedAt: "2026-08-21", updatedAt: "2026-08-22", - apiVersion: "1", - category: "asset-source", + apiVersion: "yingce.plugin/v1", description: "把 Eagle 作为影策的外部素材来源,直接浏览原始文件夹并读写 Eagle 文件。", documentation: eaglePluginDocumentation, author: "影策社区", - surfaces: ["asset-source"], permissions: ["asset.read", "asset.search", "asset.upload", "external.open"], trusted: true, - configuration: { fields: ["baseUrl", "autoUploadGenerated", "generatedFolderId"] }, + configuration: { + fields: [ + { name: "baseUrl", type: "url", label: "Eagle Base URL", required: true, default: EAGLE_DEFAULT_BASE_URL }, + { name: "autoUploadGenerated", type: "boolean", label: "自动上传生成结果", default: true }, + { name: "generatedFolderId", type: "string", label: "生成结果文件夹" }, + ], + }, + runtime: { web: "trusted-backend" }, + contributes: { assetSources: ["eagle"] }, }, createAssetSource: ({ config }: PluginHostContext) => { const configuredBaseUrl = config.baseUrl; diff --git a/web/src/lib/plugins/builtin/prompt-optimizer.ts b/web/src/lib/plugins/builtin/prompt-optimizer.ts index 7a604e88e..28cd4053a 100644 --- a/web/src/lib/plugins/builtin/prompt-optimizer.ts +++ b/web/src/lib/plugins/builtin/prompt-optimizer.ts @@ -59,13 +59,13 @@ const manifest: PluginManifest = { id: PROMPT_OPTIMIZER_PLUGIN_ID, name: "AI 提示词优化器", version: "0.3.0", - apiVersion: "1", - category: "ai-capability", + apiVersion: "yingce.plugin/v1", description: "把模糊的生图想法整理成可执行、可比较、适配当前模型的提示词。", author: "影策团队", - surfaces: ["hybrid"], permissions: ["canvas.read", "canvas.write", "ai.text"], trusted: true, + runtime: { web: "trusted-backend" }, + contributes: { aiCapabilities: ["prompt-optimizer"] }, }; const systemPrompt = [ diff --git a/web/src/lib/plugins/plugin-registry.ts b/web/src/lib/plugins/plugin-registry.ts index 12ac9a5a0..8ec267321 100644 --- a/web/src/lib/plugins/plugin-registry.ts +++ b/web/src/lib/plugins/plugin-registry.ts @@ -1,12 +1,14 @@ import type { PluginManifest, RegisteredPlugin } from "./plugin-types"; +import { registerPluginCanvasNodes, unregisterNodeDefinitions } from "@/lib/canvas/node-registry"; const registeredPlugins = new Map(); function assertManifest(manifest: PluginManifest) { if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.id)) throw new Error("插件 ID 必须使用 kebab-case"); if (!manifest.name.trim() || !manifest.version.trim() || !manifest.apiVersion.trim()) throw new Error("插件清单缺少名称、版本或 API 版本"); - if (!manifest.surfaces.length) throw new Error("插件至少需要声明一种界面形态"); + if (manifest.apiVersion !== "yingce.plugin/v1") throw new Error(`不支持的插件 API 版本:${manifest.apiVersion}`); if (new Set(manifest.permissions).size !== manifest.permissions.length) throw new Error("插件权限不能重复"); + if (!manifest.contributes || Object.values(manifest.contributes).every((value) => !value || value.length === 0)) throw new Error("插件至少需要声明一种贡献能力"); } export function registerPlugin(plugin: RegisteredPlugin) { @@ -15,10 +17,14 @@ export function registerPlugin(plugin: RegisteredPlugin) { if (existing && existing.manifest.version !== plugin.manifest.version) { throw new Error(`插件 ${plugin.manifest.id} 已注册其他版本`); } + if (plugin.manifest.contributes.canvasNodes?.length) { + registerPluginCanvasNodes(plugin.manifest.id, plugin.manifest.contributes.canvasNodes); + } registeredPlugins.set(plugin.manifest.id, plugin); } export function unregisterPlugin(pluginId: string) { + unregisterNodeDefinitions(pluginId); registeredPlugins.delete(pluginId); } diff --git a/web/src/lib/plugins/plugin-types.ts b/web/src/lib/plugins/plugin-types.ts index 063ce8b1a..e60134a45 100644 --- a/web/src/lib/plugins/plugin-types.ts +++ b/web/src/lib/plugins/plugin-types.ts @@ -1,20 +1,90 @@ import type { Asset } from "@/stores/use-asset-store"; -export const PLUGIN_API_VERSION = "1" as const; +export const PLUGIN_API_VERSION = "yingce.plugin/v1" as const; -export type PluginCategory = "asset-source" | "canvas-node" | "workflow" | "ai-capability" | "import-export" | "agent" | "protocol"; -export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source"; +export type PluginContributionKind = "provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export"; +export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings"; export type ProtocolCapability = "text" | "image" | "video" | "audio"; export type ProtocolScope = "admin.system-channel" | "user.custom-channel" | "canvas" | "creation" | "agent" | string; -export type ProtocolPluginInfo = { - categories: ProtocolCapability[]; - scopes: ProtocolScope[]; - create?: string; - poll?: string; - cancel?: string; +export type PluginRuntime = "declarative" | "sandbox" | "worker" | "trusted-backend"; +export type PluginField = { + name: string; + type: "string" | "number" | "boolean" | "secret" | "url" | "select" | "json"; + label?: string; + required?: boolean; + default?: string | number | boolean; + description?: string; + values?: string[]; +}; +export type PluginParameter = { + name: string; + type: string; + required?: boolean; + description?: string; + values?: string[]; + mapping?: string; +}; +export type PluginProviderOperation = { + method: "GET" | "POST" | "PUT" | "DELETE"; + path: string; contentType?: string; - documentation?: string; - parameters?: Array<{ name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string }>; + fields?: Record; +}; +export type PluginProviderContribution = { + id: string; + label: string; + capabilities: ProtocolCapability[]; + scopes: ProtocolScope[]; + baseUrl?: string; + auth?: { type: "bearer" | "api-key" | "custom"; field: string; header?: string }; + parameters?: PluginParameter[]; + create: PluginProviderOperation; + poll?: PluginProviderOperation; + cancel?: PluginProviderOperation; + response: { + taskIdPaths?: string[]; + statusPaths?: string[]; + messagePaths?: string[]; + textPaths?: string[]; + reasoningPaths?: string[]; + resultPaths?: string[]; + resultKind?: "image" | "video" | "audio"; + resultEphemeral?: boolean; + }; +}; +export type PluginWorkflowContribution = { + id: string; + label: string; + providerId: string; + capability: ProtocolCapability; + parameters: PluginParameter[]; + defaults?: Record; +}; +export type PluginCanvasNodeContribution = { + id: string; + label: string; + defaultTitle: string; + defaultSize: { width: number; height: number }; + schema: Record; + renderer: "declarative" | "sandbox"; +}; +export type PluginTransformContribution = { + id: string; + input: "media" | "generation"; + output: "provider-request" | "media"; + runtime: PluginRuntime; +}; +export type PluginContributions = { + providers?: PluginProviderContribution[]; + workflows?: PluginWorkflowContribution[]; + canvasNodes?: PluginCanvasNodeContribution[]; + transforms?: PluginTransformContribution[]; + commands?: Array<{ id: string; label: string }>; + assetSources?: string[]; + usageObservers?: string[]; + aiCapabilities?: string[]; + agents?: string[]; + importExport?: string[]; }; export type PluginPermission = | "canvas.read" @@ -25,28 +95,27 @@ export type PluginPermission = | "asset.upload" | "generation.run" | "ai.text" + | "media.read" + | "usage.read" | "external.open"; export type PluginManifest = { + apiVersion: typeof PLUGIN_API_VERSION; id: string; name: string; version: string; publishedAt?: string; updatedAt?: string; - apiVersion: string; - category: PluginCategory; description: string; documentation?: string; author?: string; entry?: string; - surfaces: PluginSurface[]; + surfaces?: PluginSurface[]; permissions: PluginPermission[]; trusted?: boolean; - kind?: "ui" | "protocol"; - configuration?: { - fields: string[]; - }; - protocol?: ProtocolPluginInfo; + configuration?: { fields: PluginField[] }; + runtime?: { backend?: PluginRuntime; web?: PluginRuntime }; + contributes: PluginContributions; }; export type PluginStorage = { @@ -103,6 +172,12 @@ export type PluginHostServices = { ai?: { text?: PluginAiTextService; }; + media?: { + resolve: (reference: { url?: string; dataUrl?: string; kind?: string }, signal?: AbortSignal) => Promise<{ dataUrl: string; mimeType: string }>; + }; + usage?: { + list: (scope?: string) => Promise>>; + }; }; export type PluginHostContext = { diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 4f1d53ca0..9f5832500 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -9,7 +9,7 @@ import { ModelCapabilityEditor } from "@/components/model-capability-editor"; import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker"; import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols"; -import { fetchProtocolCatalog } from "@/services/api/protocols"; +import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog"; import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; import { AdminPageFrame } from "./admin-shell"; @@ -81,7 +81,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: useEffect(() => { void reload(); - void fetchProtocolCatalog("admin.system-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([])); + void fetchPluginProviderCatalog("admin.system-channel").then(setAvailableProtocols).catch(() => setAvailableProtocols([])); setEditing(null); setEditorOpen(false); setKeyword(""); diff --git a/web/src/pages/canvas/canvas-project-context-menu.tsx b/web/src/pages/canvas/canvas-project-context-menu.tsx index a2a4e37c2..660c3c0d5 100644 --- a/web/src/pages/canvas/canvas-project-context-menu.tsx +++ b/web/src/pages/canvas/canvas-project-context-menu.tsx @@ -1,5 +1,5 @@ import { CanvasNodeContextMenu } from "@/components/canvas/canvas-context-menu"; -import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas"; +import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas"; type CanvasAssetCategory = NonNullable["assetCategory"]>; @@ -13,7 +13,7 @@ type CanvasProjectContextMenuProps = { canPaste: boolean; screenToCanvas: (clientX: number, clientY: number) => Position; onClose: () => void; - onAddNode: (type: CanvasNodeType, position: Position) => void; + onAddNode: (type: CanvasNodeTypeId, position: Position) => void; onAddFolder: (position: Position) => void; onChooseStyle: () => void; onOpenDirector: (position?: Position) => void; diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index bb482d9ab..6bfa36ad6 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -926,6 +926,7 @@ function InfiniteCanvasPage() { setContextMenu(null); setHoveredNodeId(null); setToolbarNodeId(null); + setDialogNodeId(null); }, []); const { alignmentGuides, cancelSelectionBox, deselectCanvas, dragPreview, frameDropTargetId, handleCanvasMouseDown, handleNodeMouseDown, isNodeDragging, nodeDraggingRef, selectionBoundsElementRef, selectionBox } = useCanvasSelectionController({ diff --git a/web/src/pages/canvas/use-canvas-generation-retry.ts b/web/src/pages/canvas/use-canvas-generation-retry.ts index 40be4c318..260b31781 100644 --- a/web/src/pages/canvas/use-canvas-generation-retry.ts +++ b/web/src/pages/canvas/use-canvas-generation-retry.ts @@ -32,7 +32,7 @@ import type { GenerationTask } from "@/services/api/task-center"; import { resolveImageUrl } from "@/services/image-storage"; import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import type { Asset } from "@/stores/use-asset-store"; -import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas"; type UseCanvasGenerationRetryOptions = { projectId: string; @@ -387,7 +387,7 @@ export function useCanvasGenerationRetry({ } // 生成类型必须由明确的节点契约决定,未知节点不能降级成图片任务。 -function retryModeForNode(type: CanvasNodeType): CanvasNodeGenerationMode | null { +function retryModeForNode(type: CanvasNodeTypeId): CanvasNodeGenerationMode | null { if (type === CanvasNodeType.Text) return "text"; if (type === CanvasNodeType.Image) return "image"; if (type === CanvasNodeType.Video) return "video"; diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts index b6872545a..321f53d8e 100644 --- a/web/src/pages/canvas/use-canvas-node-operations.ts +++ b/web/src/pages/canvas/use-canvas-node-operations.ts @@ -8,7 +8,7 @@ import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, g import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout"; import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain"; import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy"; -import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas"; +import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas"; import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine"; import { useUserStore } from "@/stores/use-user-store"; @@ -122,7 +122,7 @@ export function useCanvasNodeOperations({ setSelectedConnectionId(null); }, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]); - const createNode = useCallback((type: CanvasNodeType, position?: Position) => { + const createNode = useCallback((type: CanvasNodeTypeId, position?: Position) => { if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) { message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图"); return; diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index e91e5b163..241d19716 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -571,7 +571,7 @@ export function useCanvasUpload({ || (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/")) || (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/")) || (targetNode.type === CanvasNodeType.Audio && isAudioFile(file)) - || ![CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio].includes(targetNode.type); + || (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio); if (!compatible) { message.warning("请选择与当前节点相同类型的媒体文件"); return; diff --git a/web/src/pages/plugins/index.tsx b/web/src/pages/plugins/index.tsx index e12fc6161..ae01f89f5 100644 --- a/web/src/pages/plugins/index.tsx +++ b/web/src/pages/plugins/index.tsx @@ -17,13 +17,15 @@ import { PluginDetailsModal, UploadPluginModal } from "./plugin-documentation-mo import "./plugins.css"; const categoryLabels: Record = { - "asset-source": "素材来源", + provider: "模型渠道", "canvas-node": "画布节点", workflow: "工作流", + transform: "媒体转换", + "asset-source": "素材来源", "ai-capability": "AI 能力", - "import-export": "导入导出", + "usage-observer": "用量观察", agent: "智能体", - protocol: "请求协议", + "import-export": "导入导出", }; const surfaceLabels: Record = { @@ -121,13 +123,14 @@ export default function PluginsPage() { const isSystemPlugin = plugin.source !== "uploaded"; if (user?.role !== "admin" && !features.systemPluginsVisibleToUsers && isSystemPlugin) return false; const installation = installations.find((item) => item.manifest.id === plugin.manifest.id); - const enabled = plugin.manifest.kind === "protocol" ? backendPluginById.get(plugin.manifest.id)?.status === "enabled" : Boolean(installation?.enabled); + const enabled = backendPluginById.get(plugin.manifest.id)?.status === "enabled" || Boolean(installation?.enabled); const manifest = plugin.manifest; - const searchableText = [manifest.name, manifest.description, manifest.author, manifest.id, categoryLabels[manifest.category]].filter(Boolean).join(" ").toLocaleLowerCase(); + const contributionKinds = contributionKindsFor(manifest); + const searchableText = [manifest.name, manifest.description, manifest.author, manifest.id, ...contributionKinds.map((kind) => categoryLabels[kind] || kind)].filter(Boolean).join(" ").toLocaleLowerCase(); if (normalizedSearch && !searchableText.includes(normalizedSearch)) return false; if (categoryFilter !== "all") { - const matchesCapability = manifest.kind === "protocol" && manifest.protocol?.categories.includes(categoryFilter as "text" | "image" | "video" | "audio"); - const matchesApp = categoryFilter === "other" && manifest.kind !== "protocol"; + const matchesCapability = providerCapabilitiesFor(manifest).includes(categoryFilter as "text" | "image" | "video" | "audio"); + const matchesApp = categoryFilter === "other" && contributionKinds.length > 0 && !matchesCapability; if (!matchesCapability && !matchesApp) return false; } if (trustFilter === "trusted" && !manifest.trusted) return false; @@ -139,8 +142,8 @@ export default function PluginsPage() { const pluginSections = useMemo( () => [ - ...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => plugin.manifest.kind === "protocol" && plugin.manifest.protocol?.categories.includes(section.key)) })), - { key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => plugin.manifest.kind !== "protocol") }, + ...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => providerCapabilitiesFor(plugin.manifest).includes(section.key)) })), + { key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => !providerCapabilitiesFor(plugin.manifest).length) }, ], [filteredPlugins], ); @@ -149,11 +152,9 @@ export default function PluginsPage() { const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || plugin.source === "uploaded"); const counts: Record = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, other: 0 }; for (const plugin of visiblePlugins) { - if (plugin.manifest.kind !== "protocol") { - counts.other += 1; - continue; - } - for (const capability of plugin.manifest.protocol?.categories || []) { + const capabilities = providerCapabilitiesFor(plugin.manifest); + if (!capabilities.length) counts.other += 1; + for (const capability of capabilities) { counts[capability] = (counts[capability] || 0) + 1; } } @@ -162,23 +163,23 @@ export default function PluginsPage() { const settingsPlugin = settingsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === settingsPluginId) : undefined; const settingsInstallation = settingsPlugin ? installations.find((item) => item.manifest.id === settingsPlugin.manifest.id) : undefined; - const settingsEnabled = settingsPlugin?.manifest.kind === "protocol" ? backendPluginById.get(settingsPlugin.manifest.id)?.status === "enabled" : Boolean(settingsInstallation?.enabled); + const settingsEnabled = settingsPlugin ? backendPluginById.get(settingsPlugin.manifest.id)?.status === "enabled" || Boolean(settingsInstallation?.enabled) : false; const detailsPlugin = detailsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === detailsPluginId) : undefined; - const hasPluginConfiguration = (plugin: RegisteredPlugin) => Boolean(plugin.manifest.configuration?.fields.length); + const hasPluginConfiguration = (plugin: RegisteredPlugin) => Boolean(plugin.manifest.configuration?.fields?.length); const canConfigurePlugin = (plugin: RegisteredPlugin) => hasPluginConfiguration(plugin) && user?.role === "admin"; const isPluginEnabled = (plugin: RegisteredPlugin, installation = installations.find((item) => item.manifest.id === plugin.manifest.id)) => - plugin.manifest.kind === "protocol" ? backendPluginById.get(plugin.manifest.id)?.status === "enabled" : Boolean(installation?.enabled); + backendPluginById.get(plugin.manifest.id)?.status === "enabled" || Boolean(installation?.enabled); const togglePlugin = async (plugin: RegisteredPlugin, enabled: boolean) => { - if (plugin.manifest.kind !== "protocol") { - setEnabled(plugin.manifest.id, enabled); - return; - } try { - const next = await setPluginEnabled(plugin.manifest.id, enabled); - setBackendPlugins((items) => items.map((item) => (item.manifest.id === next.manifest.id ? next : item))); + if (backendPluginById.has(plugin.manifest.id)) { + const next = await setPluginEnabled(plugin.manifest.id, enabled); + setBackendPlugins((items) => items.map((item) => (item.manifest.id === next.manifest.id ? next : item))); + } else { + setEnabled(plugin.manifest.id, enabled); + } message.success(`${plugin.manifest.name}${enabled ? "已启用" : "已停用"}`); } catch (error) { message.error(error instanceof Error ? error.message : "更新插件状态失败"); @@ -228,7 +229,7 @@ export default function PluginsPage() {
PLUGIN CENTER

插件中心

-

统一管理能力插件与模型请求协议。

+

统一管理 provider、工作流、画布节点和其他扩展能力。