feat(extensions): show the bundled catalog by default (#6188)

* feat(extensions): show the bundled catalog by default

* test(extensions): pin bundled catalog namespaces
This commit is contained in:
Wenchao An 2026-10-02 23:01:34 +08:00 committed by GitHub
parent 19b7590333
commit 9ff47cfae5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 319 additions and 35 deletions

View File

@ -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`.

View File

@ -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 <p role="status">{t.extensions.loading}</p>;
if (source.isError)
return (
<div role="alert">
<p>{t.extensions.unavailable}</p>
<Button variant="outline" onClick={() => void source.refetch()}>
{t.extensions.retry}
</Button>
</div>
);
const status = source.isPending ? (
<p role="status">{t.extensions.loading}</p>
) : source.isError ? (
<div role="alert">
<p>{t.extensions.unavailable}</p>
<Button variant="outline" onClick={() => void source.refetch()}>
{t.extensions.retry}
</Button>
</div>
) : null;
const reload = (
<Button variant="outline" onClick={() => window.location.reload()}>
{t.extensions.reloadAll}
</Button>
);
if (selected) {
const entry = entries.find((item) => item.namespace === selected);
const entry = entries.find((item) => item.id === selected);
return (
<div className="space-y-6">
{reload}
{status}
<Button variant="ghost" onClick={() => select()}>
<ArrowLeftIcon />
{t.extensions.all}
@ -57,35 +62,45 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
<h2 className="text-2xl font-semibold">{entry.title}</h2>
<p className="text-muted-foreground">{entry.description}</p>
<p>
{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}
</p>
{entry.guide && (
<Button variant="outline" asChild>
<a href={entry.guide} target="_blank" rel="noopener noreferrer">
{t.extensions.installationGuide}
</a>
</Button>
)}
</div>
)}
</div>
);
}
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 (
<div className="space-y-5">
{reload}
{status}
<p className="text-muted-foreground text-sm">
{t.extensions.deploymentHint}
</p>
<div className="grid gap-x-10 md:grid-cols-2">
{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 (
<PluginRow
key={entry.namespace}
key={entry.id}
name={entry.title}
description={entry.description}
icon={
@ -96,18 +111,20 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
label={
loaded?.error
? t.extensions.moduleUnavailable
: entry.settings.enabled === true
? t.capabilities.enabled
: t.capabilities.disabled
: loaded
? loaded.settings.enabled === true
? t.capabilities.enabled
: t.capabilities.disabled
: t.extensions.catalogEntry
}
onDetails={() => select(entry.namespace)}
onDetails={() => select(entry.id)}
detailsLabel={t.extensions.view(entry.title)}
>
<Button
variant="ghost"
size="icon"
aria-label={t.extensions.open(entry.title)}
onClick={() => select(entry.namespace)}
onClick={() => select(entry.id)}
>
<ChevronRightIcon />
</Button>

View File

@ -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<string | null>(
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;
}

View File

@ -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.",

View File

@ -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;

View File

@ -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: "查看扩展",

View File

@ -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);
});

View File

@ -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,
);
});