From 9ff47cfae50280d772bcbeee8f69ee988873bbf7 Mon Sep 17 00:00:00 2001 From: Wenchao An Date: Fri, 2 Oct 2026 23:01:34 +0800 Subject: [PATCH] feat(extensions): show the bundled catalog by default (#6188) * feat(extensions): show the bundled catalog by default * test(extensions): pin bundled catalog namespaces --- frontend/AGENTS.md | 8 +- .../capabilities/extension-gallery.tsx | 69 ++++++++++------ frontend/src/core/extensions/catalog.ts | 66 +++++++++++++++ frontend/src/core/i18n/locales/en-US.ts | 33 +++++++- frontend/src/core/i18n/locales/types.ts | 7 ++ frontend/src/core/i18n/locales/zh-CN.ts | 30 ++++++- frontend/tests/e2e/bookmark-plugin.spec.ts | 61 ++++++++++++-- .../unit/core/extensions/catalog.test.ts | 80 +++++++++++++++++++ 8 files changed, 319 insertions(+), 35 deletions(-) create mode 100644 frontend/src/core/extensions/catalog.ts create mode 100644 frontend/tests/unit/core/extensions/catalog.test.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 571df94619..29314ea431 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -297,8 +297,12 @@ Both honor the backend base and prefixes; transport and cache semantics are docu Conversation action factories, shapes and availability callbacks are guarded per plugin; only validated value snapshots reach the toolbar/sidebar render paths. `PluginNavigation` and the dynamic workspace extension route consume page declarations; -Capability Center details only show metadata and status. Conversation action slots augment -normal/custom-agent toolbars and sidebar menus without replacing native export or notification. +Capability Center defaults to the repository examples in `core/extensions/catalog.ts`, +merged by explicit namespace with runtime descriptors. Catalog-only entries are discovery +metadata, never module-loader inputs or proof of installation. Backend-only examples may +have no plugin descriptor; keep their runtime status unasserted. Details link to package +installation instructions. Keep the catalog aligned with `examples/deerflow-extension-*`. +Conversation action slots augment normal/custom-agent toolbars and sidebar menus without replacing native export or notification. Plugin views use mount/dispose and abort signals; Shadow DOM is CSS isolation, not a sandbox. Descriptors are user-keyed page snapshots, refreshed manually. Backend calls bind the plugin's namespace, action allowlist and expected viewer identity. See `docs/full-stack-plugins.md`. diff --git a/frontend/src/components/workspace/capabilities/extension-gallery.tsx b/frontend/src/components/workspace/capabilities/extension-gallery.tsx index 86ec177d1d..207c8229a8 100644 --- a/frontend/src/components/workspace/capabilities/extension-gallery.tsx +++ b/frontend/src/components/workspace/capabilities/extension-gallery.tsx @@ -4,6 +4,7 @@ import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; +import { extensionDirectory } from "@/core/extensions/catalog"; import { useFrontendExtensions } from "@/core/extensions/hooks"; import { extensionIcon } from "@/core/extensions/registry"; import { useI18n } from "@/core/i18n/hooks"; @@ -17,7 +18,10 @@ export function ExtensionGallery({ query = "" }: { query?: string }) { const router = useRouter(); const pathname = usePathname(); const selected = params.get("extension"); - const entries = publicQuery.data ?? []; + const entries = extensionDirectory( + publicQuery.data ?? [], + t.extensions.catalog, + ); const source = publicQuery; function select(namespace?: string) { const next = new URLSearchParams(params); @@ -26,26 +30,27 @@ export function ExtensionGallery({ query = "" }: { query?: string }) { else next.delete("extension"); router.replace(`${pathname}?${next.toString()}`, { scroll: false }); } - if (source.isPending) return

{t.extensions.loading}

; - if (source.isError) - return ( -
-

{t.extensions.unavailable}

- -
- ); + const status = source.isPending ? ( +

{t.extensions.loading}

+ ) : source.isError ? ( +
+

{t.extensions.unavailable}

+ +
+ ) : null; const reload = ( ); if (selected) { - const entry = entries.find((item) => item.namespace === selected); + const entry = entries.find((item) => item.id === selected); return (
{reload} + {status} + )}
)} ); } const visible = entries.filter((entry) => - `${entry.title} ${entry.description}` + `${entry.title} ${entry.description} ${entry.package ?? ""} ${entry.id} ${entry.loaded?.title ?? ""} ${entry.loaded?.description ?? ""}` .toLowerCase() .includes(query.trim().toLowerCase()), ); return (
{reload} + {status}

{t.extensions.deploymentHint}

{visible.map((entry) => { - const loaded = publicQuery.data?.find( - (item) => item.namespace === entry.namespace, - ); - const Icon = extensionIcon(loaded?.extension?.icon); + const loaded = entry.loaded; + const Icon = extensionIcon(loaded?.extension?.icon ?? entry.icon); return ( select(entry.namespace)} + onDetails={() => select(entry.id)} detailsLabel={t.extensions.view(entry.title)} > diff --git a/frontend/src/core/extensions/catalog.ts b/frontend/src/core/extensions/catalog.ts new file mode 100644 index 0000000000..886603f8a5 --- /dev/null +++ b/frontend/src/core/extensions/catalog.ts @@ -0,0 +1,66 @@ +import type { Translations } from "@/core/i18n/locales/types"; + +import type { LoadedContribution } from "./registry"; + +// Discovery metadata only: catalog entries never enter the runtime module loader. +export const extensionCatalog = [ + { + key: "bookmarks", + package: "deerflow-extension-bookmarks", + namespace: "community.bookmarks", + icon: "bookmark", + }, + { + key: "context", + package: "deerflow-extension-jev-context", + namespace: "community.jev-context", + }, + { + key: "classify", + package: "deerflow-extension-jev-classify", + namespace: "community.jev-classify", + }, + { + key: "screening", + package: "deerflow-extension-jev-screening", + namespace: null, + }, + { key: "example", package: "deerflow-extension-example", namespace: null }, +] as const; + +export interface ExtensionCatalogEntry { + id: string; + title: string; + description: string; + package?: string; + guide?: string; + icon?: string; + loaded?: LoadedContribution; +} + +export function extensionDirectory( + loaded: LoadedContribution[], + copy: Translations["extensions"]["catalog"], +): ExtensionCatalogEntry[] { + const entries: ExtensionCatalogEntry[] = extensionCatalog.map((item) => ({ + id: item.namespace ?? `catalog/${item.key}`, + ...copy[item.key], + package: item.package, + guide: `https://github.com/bytedance/deer-flow/tree/main/examples/${item.package}#readme`, + icon: "icon" in item ? item.icon : undefined, + loaded: loaded.find((entry) => entry.namespace === item.namespace), + })); + const known = new Set( + extensionCatalog.map((item) => item.namespace), + ); + for (const entry of loaded) { + if (known.has(entry.namespace)) continue; + entries.push({ + id: entry.namespace, + title: entry.title, + description: entry.description, + loaded: entry, + }); + } + return entries; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 06c0fc0c54..9f7b43c9e4 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -34,7 +34,38 @@ export const enUS: Translations = { deploymentHint: "Interface and browser features update on manual reload. Installation, activation and configuration are managed through deployment configuration or the CLI.", moduleUnavailable: "Page module unavailable", - noResults: "No matching installed extensions.", + noResults: "No matching extensions.", + catalogEntry: "Catalog extension", + catalogHint: + "This entry comes from the repository extension catalog. Ask your deployment administrator to confirm its installation and activation status.", + installationGuide: "Installation guide", + catalog: { + bookmarks: { + title: "Bookmarks", + description: + "Save useful answers and find and organize your own bookmarks on a dedicated page.", + }, + context: { + title: "Context pruning", + description: + "Shorten older read-only tool results while preserving recent messages and native summaries.", + }, + classify: { + title: "Text classification", + description: + "Label a list of texts with supplied categories using the deployment-configured classification service.", + }, + screening: { + title: "Content risk warnings", + description: + "Screen fetched content and add advisory warnings to suspicious tool results without blocking tools.", + }, + example: { + title: "Extension development example", + description: + "Explore middleware, task lifecycle, model observers, services and HTTP route contributions.", + }, + }, pageUnavailable: "Extension page unavailable", pageUnavailableHint: "This page is not registered, or its plugin is disabled or unavailable.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 6bdba34a89..e70978009d 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -23,6 +23,13 @@ export interface Translations { deploymentHint: string; moduleUnavailable: string; noResults: string; + catalogEntry: string; + catalogHint: string; + installationGuide: string; + catalog: Record< + "bookmarks" | "context" | "classify" | "screening" | "example", + { title: string; description: string } + >; pageUnavailable: string; pageUnavailableHint: string; viewAll: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 676dc994d9..245ef788eb 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -34,7 +34,35 @@ export const zhCN: Translations = { deploymentHint: "界面和浏览器功能在手动刷新后更新;安装、启停和配置由部署管理员通过配置文件或 CLI 管理。", moduleUnavailable: "当前页面加载失败", - noResults: "没有匹配的已安装扩展。", + noResults: "没有匹配的扩展。", + catalogEntry: "扩展目录", + catalogHint: + "此条目来自仓库扩展目录;实际安装和启用状态请由部署管理员确认。", + installationGuide: "查看安装说明", + catalog: { + bookmarks: { + title: "会话书签", + description: "收藏有用的回答,在独立页面查找与整理自己的书签。", + }, + context: { + title: "上下文裁剪", + description: "按需缩短旧的只读工具结果,保留近期消息与原生摘要。", + }, + classify: { + title: "文本分类", + description: "按给定类别为文本列表打标签,使用部署方配置的分类服务。", + }, + screening: { + title: "内容风险提示", + description: + "检查获取的外部内容,为可疑工具结果添加提示,不阻断工具执行。", + }, + example: { + title: "扩展开发示例", + description: + "演示中间件、任务生命周期、模型观察器、服务和 HTTP 路由的扩展方式。", + }, + }, pageUnavailable: "扩展页面不可用", pageUnavailableHint: "此页面未注册,或插件已停用、未能加载。", viewAll: "查看扩展", diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts index 8617ed9b5c..0a2ea1cc28 100644 --- a/frontend/tests/e2e/bookmark-plugin.spec.ts +++ b/frontend/tests/e2e/bookmark-plugin.spec.ts @@ -201,7 +201,7 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) { }); } await page - .getByRole("button", { name: "View 会话书签 / Bookmarks", exact: true }) + .getByRole("button", { name: "View Bookmarks", exact: true }) .click(); await expect( page.getByRole("searchbox", { name: "Search bookmarks" }), @@ -442,11 +442,45 @@ for (const locale of ["en-US", "zh-CN"]) { exact: true, }), ).toBeVisible(); + await expect(page.getByRole("article")).toHaveCount(5); await expect( - page.getByText( - zh ? "没有匹配的已安装扩展。" : "No matching installed extensions.", - { exact: true }, - ), + page.getByRole("button", { + name: zh ? "查看 会话书签" : "View Bookmarks", + exact: true, + }), + ).toBeVisible(); + await page + .getByPlaceholder( + zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose", + ) + .fill("jev-screening"); + await expect(page.getByRole("article")).toHaveCount(1); + await page + .getByRole("button", { + name: zh ? "查看 内容风险提示" : "View Content risk warnings", + exact: true, + }) + .click(); + await expect( + page.getByRole("link", { + name: zh ? "查看安装说明" : "Installation guide", + }), + ).toHaveAttribute("href", /deerflow-extension-jev-screening#readme$/); + await page + .getByRole("button", { + name: zh ? "全部扩展" : "All extensions", + exact: true, + }) + .click(); + await page + .getByPlaceholder( + zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose", + ) + .fill("no-such-extension"); + await expect( + page.getByText(zh ? "没有匹配的扩展。" : "No matching extensions.", { + exact: true, + }), ).toBeVisible(); await page.goto("/workspace/extensions/missing.plugin/library"); await expect( @@ -470,3 +504,20 @@ test("unregistered plugin pages stay unavailable", async ({ page }) => { page.getByRole("searchbox", { name: "Search bookmarks" }), ).toHaveCount(0); }); + +test("catalog remains discoverable when runtime discovery fails", async ({ + page, +}) => { + mockLangGraphAPI(page); + await page.route("**/api/plugins", (route) => + route.fulfill({ status: 503, json: { detail: "Unavailable" } }), + ); + await page.goto("/workspace/capabilities?tab=extensions"); + await expect( + page.getByRole("alert").filter({ hasText: "Extensions unavailable." }), + ).toBeVisible(); + await expect(page.getByRole("article")).toHaveCount(5); + await expect( + page.getByRole("article").filter({ hasText: "Catalog extension" }), + ).toHaveCount(5); +}); diff --git a/frontend/tests/unit/core/extensions/catalog.test.ts b/frontend/tests/unit/core/extensions/catalog.test.ts new file mode 100644 index 0000000000..0dab6b4f4d --- /dev/null +++ b/frontend/tests/unit/core/extensions/catalog.test.ts @@ -0,0 +1,80 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@rstest/core"; + +import { + extensionCatalog, + extensionDirectory, +} from "@/core/extensions/catalog"; +import type { LoadedContribution } from "@/core/extensions/registry"; +import { enUS } from "@/core/i18n/locales/en-US"; + +const contribution: LoadedContribution = { + namespace: "community.bookmarks", + module: null, + entry: null, + title: "Deployment bookmarks", + description: "Configured bookmarks", + settings: { enabled: false }, +}; + +test("catalog covers every bundled extension package even without runtime registrations", () => { + const packages = readdirSync(resolve(process.cwd(), "../examples")).filter( + (name) => name.startsWith("deerflow-extension-"), + ); + expect(extensionCatalog.map((entry) => entry.package).sort()).toEqual( + packages.sort(), + ); + const entries = extensionDirectory([], enUS.extensions.catalog); + expect(entries).toHaveLength(packages.length); + expect(entries.every((entry) => entry.loaded === undefined)).toBe(true); +}); + +test("catalog merge namespaces match the corresponding bundled plugin declarations", () => { + for (const entry of extensionCatalog) { + const directory = resolve(process.cwd(), "../examples", entry.package); + const namespaces = readdirSync(directory, { recursive: true }) + .filter((path) => typeof path === "string" && path.endsWith(".py")) + .flatMap((path) => + [ + ...readFileSync(resolve(directory, String(path)), "utf8").matchAll( + /\bnamespace\s*=\s*["']([^"']+)["']/g, + ), + ].map((match) => match[1]), + ); + expect(namespaces, entry.package).toEqual( + entry.namespace === null ? [] : [entry.namespace], + ); + } +}); + +test("merges by namespace without duplicating catalog entries or guessing backend-only status", () => { + const entries = extensionDirectory([contribution], enUS.extensions.catalog); + expect(entries).toHaveLength(extensionCatalog.length); + expect( + entries.find((entry) => entry.id === contribution.namespace)?.loaded + ?.settings.enabled, + ).toBe(false); + expect( + entries.find( + (entry) => entry.package === "deerflow-extension-jev-screening", + )?.loaded, + ).toBeUndefined(); +}); + +test("preserves custom deployment extensions and module failures", () => { + const failed = { + ...contribution, + namespace: "company.custom", + error: "module failed", + }; + const entries = extensionDirectory([failed], enUS.extensions.catalog); + expect(entries).toHaveLength(extensionCatalog.length + 1); + expect(entries.find((entry) => entry.id === failed.namespace)?.loaded).toBe( + failed, + ); + expect(entries.find((entry) => entry.id === failed.namespace)?.title).toBe( + failed.title, + ); +});