diff --git a/backend/internal/app/plugin_management.go b/backend/internal/app/plugin_management.go index 368dcfd5d..c06437d73 100644 --- a/backend/internal/app/plugin_management.go +++ b/backend/internal/app/plugin_management.go @@ -14,6 +14,7 @@ const ( PluginPortraitClearance = "portrait-clearance" PluginAIArtCritique = "ai-art-critique" PluginMediaConversion = "media-conversion" + PluginEditorShell = "editor-shell" PluginOriginOfficial = "official" PluginOriginSystem = "system" @@ -81,6 +82,10 @@ var officialApplicationPolicies = map[string]PluginManagementView{ Origin: PluginOriginOfficial, Kind: PluginKindApplication, ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone, }, + PluginEditorShell: { + Origin: PluginOriginOfficial, Kind: PluginKindApplication, + ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone, + }, } var systemPaymentPolicies = map[string]PluginManagementView{ diff --git a/backend/internal/app/plugin_management_test.go b/backend/internal/app/plugin_management_test.go index 703924d69..4a2672bf5 100644 --- a/backend/internal/app/plugin_management_test.go +++ b/backend/internal/app/plugin_management_test.go @@ -76,3 +76,44 @@ func TestArtCritiqueIsUserToggleableApplication(t *testing.T) { t.Fatalf("AI art critique policy = %#v", policy) } } + +// Regression: editor-shell was missing from officialApplicationPolicies, so the +// backend classified it as a system protocol and reported it to the admin page +// as "管理员已停用该插件" even though nothing disabled it. +func TestEditorShellIsUserToggleableApplication(t *testing.T) { + policy := pluginManagement(PluginEditorShell, "bundled") + if policy.Origin != PluginOriginOfficial || policy.Kind != PluginKindApplication || policy.ActivationScope != PluginScopeUser || policy.ConfigurationScope != PluginConfigurationNone { + t.Fatalf("editor shell policy = %#v", policy) + } +} + +func TestEditorShellReportsPlatformAvailableWithoutPlatformState(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.PluginPlatformState{}, &model.UserPluginState{}, &model.AdminAuditEvent{}); err != nil { + t.Fatal(err) + } + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + svc := &Service{repo: repository.New(db), pluginRuntime: center} + user := &model.User{ID: "user-1", Role: model.UserRoleUser} + + states, err := svc.PluginStatesForUser(user) + if err != nil { + t.Fatal(err) + } + state, ok := states[PluginEditorShell] + if !ok { + t.Fatalf("editor shell missing from plugin states: %#v", states) + } + if !state.PlatformAvailable || !state.CanToggle { + t.Fatalf("editor shell should be a user-toggleable application, got %#v", state) + } + if state.BlockedReason == "管理员已停用该插件" { + t.Fatalf("editor shell reported as admin-disabled: %#v", state) + } +} diff --git a/web/src/lib/plugins/builtin/editor/editor-shell.tsx b/web/src/lib/plugins/builtin/editor/editor-shell.tsx index 4ee28ba5f..12b3bdc91 100644 --- a/web/src/lib/plugins/builtin/editor/editor-shell.tsx +++ b/web/src/lib/plugins/builtin/editor/editor-shell.tsx @@ -10,9 +10,11 @@ import { EditorTranscription } from "./editor-transcription"; import { EditorExport } from "./editor-export"; import { EditorAiAssistant } from "./editor-ai-assistant"; +export const EDITOR_SHELL_PLUGIN_ID = "editor-shell"; + const manifest: PluginManifestV2 = { apiVersion: "yingce.plugin/v2", - id: "editor-shell", + id: EDITOR_SHELL_PLUGIN_ID, name: "剪辑工作台", version: "0.1.0", description: "注册时间线、预览、检查器、素材、字幕、转写、导出和 AI 编辑八个工作台插槽。", diff --git a/web/src/lib/plugins/official-applications.ts b/web/src/lib/plugins/official-applications.ts new file mode 100644 index 000000000..ae5b2c689 --- /dev/null +++ b/web/src/lib/plugins/official-applications.ts @@ -0,0 +1,27 @@ +import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts"; +import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle"; +import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; +import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; + +import { EDITOR_SHELL_PLUGIN_ID } from "./builtin/editor/editor-shell"; + +/** + * 官方“应用型”插件清单:这些插件在管理页按用户自主启停处理,而不是系统协议。 + * + * 之前管理页、用户插件页和后端各自维护一份清单,管理页漏掉 AI 审美分析和剪辑 + * 工作台,导致二者被误判为系统协议并在管理页显示“已停用”。这里收敛为唯一来源。 + */ +export const OFFICIAL_APPLICATION_PLUGIN_IDS = [ + RUNNINGHUB_PLUGIN_ID, + EAGLE_PLUGIN_ID, + PROMPT_OPTIMIZER_PLUGIN_ID, + ART_CRITIQUE_PLUGIN_ID, + EDITOR_SHELL_PLUGIN_ID, +] as const; + +const officialApplicationIdSet = new Set(OFFICIAL_APPLICATION_PLUGIN_IDS); + +/** 判断插件是否为官方应用型插件(用户可在插件页自主启停)。 */ +export function isOfficialApplicationPluginId(pluginId: string) { + return officialApplicationIdSet.has(pluginId); +} diff --git a/web/src/pages/admin/plugins/plugins-page.tsx b/web/src/pages/admin/plugins/plugins-page.tsx index 44e96a9cf..108d85b0d 100644 --- a/web/src/pages/admin/plugins/plugins-page.tsx +++ b/web/src/pages/admin/plugins/plugins-page.tsx @@ -8,8 +8,8 @@ import { useEffect, useMemo, useState } from "react"; import { PaginationBar } from "@/components/layout/workspace-page"; import "@/lib/plugins/builtin"; import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle"; -import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; +import { isOfficialApplicationPluginId } from "@/lib/plugins/official-applications"; import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry"; import type { PluginManifest, PluginManifestV2 } from "@/lib/plugins/plugin-types"; import { fetchAdminPlugins, setPluginPlatformAvailability, uninstallPlugin, uploadPlugin, type AdminPluginState, type BackendPlugin, type PluginManagement } from "@/services/api/plugins"; @@ -26,8 +26,6 @@ type AdminPluginItem = { error?: string; }; -const officialApplicationIds = new Set([RUNNINGHUB_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID]); - export default function AdminPluginsPage() { const { message, modal } = App.useApp(); const [plugins, setPlugins] = useState([]); @@ -362,7 +360,7 @@ function PluginBrandIcon({ pluginId }: { pluginId: string }) { function mergePlugins(remote: BackendPlugin[]): AdminPluginItem[] { const byId = new Map(); for (const plugin of listRegisteredPlugins()) { - const application = officialApplicationIds.has(plugin.manifest.id); + const application = isOfficialApplicationPluginId(plugin.manifest.id); byId.set(plugin.manifest.id, { manifest: plugin.manifest, source: plugin.source || "bundled", diff --git a/web/src/pages/plugins/index.tsx b/web/src/pages/plugins/index.tsx index 67ce8f6cf..28fe6391c 100644 --- a/web/src/pages/plugins/index.tsx +++ b/web/src/pages/plugins/index.tsx @@ -10,7 +10,7 @@ import "@/lib/plugins/builtin"; import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle"; import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; -import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts"; +import { isOfficialApplicationPluginId } from "@/lib/plugins/official-applications"; import type { PluginManifest, PluginManifestV2, RegisteredPlugin } from "@/lib/plugins/plugin-types"; import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle"; import { fetchPlugins, setUserPluginEnabled, type BackendPlugin, type PluginState } from "@/services/api/plugins"; @@ -141,7 +141,7 @@ export default function PluginsPage() { const normalizedSearch = search.trim().toLocaleLowerCase(); return registeredPlugins.filter((plugin) => { const state = pluginStates[plugin.manifest.id]; - const isApplicationPlugin = backendPluginById.get(plugin.manifest.id)?.management.kind === "application" || isOfficialApplicationPlugin(plugin.manifest.id); + const isApplicationPlugin = backendPluginById.get(plugin.manifest.id)?.management.kind === "application" || isOfficialApplicationPluginId(plugin.manifest.id); if (user?.role !== "admin" && !features.systemPluginsVisibleToUsers && !isApplicationPlugin) return false; const installation = installations.find((item) => item.manifest.id === plugin.manifest.id); const enabled = state?.effectiveEnabled ?? Boolean(installation?.enabled); @@ -185,7 +185,7 @@ export default function PluginsPage() { }, [pluginSections, scrollTarget]); const categoryCounts = useMemo(() => { - const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || isOfficialApplicationPlugin(plugin.manifest.id)); + const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || isOfficialApplicationPluginId(plugin.manifest.id)); const counts: Record = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, payment: 0, other: 0 }; for (const plugin of visiblePlugins) { for (const section of protocolSectionMeta) { @@ -626,14 +626,10 @@ function toRegisteredPlugin(plugin: BackendPlugin): RegisteredPlugin { return { manifest: plugin.manifest, source: plugin.source }; } -function isOfficialApplicationPlugin(pluginId: string) { - return [RUNNINGHUB_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, ART_CRITIQUE_PLUGIN_ID].includes(pluginId); -} - function pluginSourceLabel(plugin: RegisteredPlugin, state?: PluginState) { if (plugin.source === "uploaded") return "自定义插件"; if (plugin.source === "system") return "系统插件"; - if (state?.canToggle || isOfficialApplicationPlugin(plugin.manifest.id)) return "官方插件"; + if (state?.canToggle || isOfficialApplicationPluginId(plugin.manifest.id)) return "官方插件"; return "系统插件"; } diff --git a/web/test/official-application-plugins.test.ts b/web/test/official-application-plugins.test.ts new file mode 100644 index 000000000..7c2efb269 --- /dev/null +++ b/web/test/official-application-plugins.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + OFFICIAL_APPLICATION_PLUGIN_IDS, + isOfficialApplicationPluginId, +} from "../src/lib/plugins/official-applications"; + +describe("official application plugin classification", () => { + test("covers every built-in application plugin, including editor and art critique", () => { + // Regression: the admin plugin page kept its own list and missed these + // two, so both rendered as disabled system protocols. + expect(isOfficialApplicationPluginId("editor-shell")).toBe(true); + expect(isOfficialApplicationPluginId("ai-art-critique")).toBe(true); + expect(isOfficialApplicationPluginId("prompt-optimizer")).toBe(true); + expect(isOfficialApplicationPluginId("eagle-asset-connector")).toBe(true); + expect(isOfficialApplicationPluginId("runninghub-workflow-provider")).toBe(true); + }); + + test("does not treat uploaded or unknown plugins as official applications", () => { + expect(isOfficialApplicationPluginId("some-uploaded-plugin")).toBe(false); + expect(isOfficialApplicationPluginId("")).toBe(false); + expect(isOfficialApplicationPluginId("portrait-clearance")).toBe(false); + expect(isOfficialApplicationPluginId("media-conversion")).toBe(false); + expect(isOfficialApplicationPluginId("comfyui-workflow-provider")).toBe(false); + }); + + test("exposes a single de-duplicated id list", () => { + const ids = [...OFFICIAL_APPLICATION_PLUGIN_IDS]; + expect(new Set(ids).size).toBe(ids.length); + expect(ids.length).toBe(5); + }); + + test("both plugin pages consume the shared list instead of a local copy", () => { + const adminPage = readFileSync( + resolve(import.meta.dir, "../src/pages/admin/plugins/plugins-page.tsx"), + "utf8", + ); + const userPage = readFileSync( + resolve(import.meta.dir, "../src/pages/plugins/index.tsx"), + "utf8", + ); + + expect(adminPage).toContain("isOfficialApplicationPluginId"); + expect(userPage).toContain("isOfficialApplicationPluginId"); + + // No page may reintroduce a hand-maintained id array. + expect(adminPage).not.toContain("const officialApplicationIds = new Set(["); + expect(userPage).not.toContain("function isOfficialApplicationPlugin(pluginId: string) {"); + }); +});