mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
19b7590333
commit
9ff47cfae5
@ -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`.
|
||||
|
||||
@ -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>
|
||||
|
||||
66
frontend/src/core/extensions/catalog.ts
Normal file
66
frontend/src/core/extensions/catalog.ts
Normal 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;
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "查看扩展",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
80
frontend/tests/unit/core/extensions/catalog.test.ts
Normal file
80
frontend/tests/unit/core/extensions/catalog.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user