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}
-
void source.refetch()}>
- {t.extensions.retry}
-
-
- );
+ const status = source.isPending ? (
+ {t.extensions.loading}
+ ) : source.isError ? (
+
+
{t.extensions.unavailable}
+
void source.refetch()}>
+ {t.extensions.retry}
+
+
+ ) : null;
const reload = (
window.location.reload()}>
{t.extensions.reloadAll}
);
if (selected) {
- const entry = entries.find((item) => item.namespace === selected);
+ const entry = entries.find((item) => item.id === selected);
return (
{reload}
+ {status}
select()}>
{t.extensions.all}
@@ -57,35 +62,45 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
{entry.title}
{entry.description}
- {entry.settings.enabled === true
- ? t.extensions.enabledManaged
- : t.extensions.disabledManaged}
+ {entry.loaded?.error
+ ? t.extensions.moduleUnavailable
+ : entry.loaded
+ ? entry.loaded.settings.enabled === true
+ ? t.extensions.enabledManaged
+ : t.extensions.disabledManaged
+ : t.extensions.catalogHint}
+ {entry.guide && (
+
+
+ {t.extensions.installationGuide}
+
+
+ )}
)}
);
}
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)}
>
select(entry.namespace)}
+ onClick={() => select(entry.id)}
>
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,
+ );
+});